Class: Pikuri::Agent
- Inherits:
-
Object
- Object
- Pikuri::Agent
- Defined in:
- lib/pikuri/agent.rb,
lib/pikuri/agent/event.rb,
lib/pikuri/agent/control.rb,
lib/pikuri/agent/listener.rb,
lib/pikuri/agent/extension.rb,
lib/pikuri/agent/synthesizer.rb,
lib/pikuri/agent/configurator.rb,
lib/pikuri/agent/listener_list.rb,
lib/pikuri/agent/chat_transport.rb,
lib/pikuri/agent/extension_context.rb,
lib/pikuri/agent/listener/terminal.rb,
lib/pikuri/agent/control/interloper.rb,
lib/pikuri/agent/control/step_limit.rb,
lib/pikuri/agent/listener/token_log.rb,
lib/pikuri/agent/control/cancellable.rb,
lib/pikuri/agent/listener/rate_limited.rb,
lib/pikuri/agent/context_window_detector.rb,
lib/pikuri/agent/listener/in_memory_event_list.rb
Overview
Thin wrapper around RubyLLM::Chat: pikuri owns the *extension surface* (the event-stream listeners that consume normalized chat events, plus the controls that signal back into the loop) while ruby_llm owns the loop itself. The Thought / Tool-call / Observation iteration lives in Chat#complete; pikuri’s job is wiring ruby_llm’s three callbacks at construction time, emitting Event variants from each, and forwarding control signals (step-budget tick, cancellation check, mid-loop input drain) to the appropriate Control.
Roles in this file
Two seams are visible:
-
Listeners (ListenerList + Listener::Base subclasses) — pure consumers of the event stream; they never write back. The
Agentemits every loop-narration Event variant; extensions emit their own domain events through the ExtensionContext capability facade handed to Extension#bind. There is no public path from anAgentreference to emission — nolistenersreader, nochatreader, no emit method — so holding an agent grants read access to its configuration and nothing more. New rendering or capture targets (a web sink, a structured log) are added as listeners without touching Agent. -
Controls (Control::StepLimit, Control::Cancellable, Control::Interloper) — host-facing signal holders. The
Agentreads from them at well-defined boundaries: Control::StepLimit#tick! on everybefore_tool_call(and Control::StepLimit#reset! at turn start), Control::Cancellable#check! on everybefore_tool_call(and Control::Cancellable#reset! at turn start), Control::Interloper#drain! on everyafter_tool_result.
The two roles are named separately so “what fires when” is a single grep for @listeners.emit in this file (loop narration) plus the capability calls in ExtensionContext (domain events).
Step-exhaustion policy
If the step_limit: Control::StepLimit trips during completion, #run_loop catches the Exceeded exception and applies the budget’s Control::StepLimit#on_exhausted policy (see that class header for how hosts pick):
-
:raise(the default) — re-raise to the host, same shape as the cancellation rescue below. The chat history survives and Control::StepLimit#reset! fires at the next turn boundary, so a REPL user can simply say “continue”. -
:synthesize— emit an Event::FallbackNotice and run the Synthesizer prompt on a nested tools-freeAgent(the same construction shape theagenttool frompikuri-subagentsuses for sub-agents): parent’s listener stream derived via ListenerList#for_sub_agent (Terminal padded, TokenLog zeroed, recorder shared by reference), parent’scancellableshared so a user cancel during synthesis still works, a defensivestep_limitat max: 1 (the synth has no tools and shouldn’t tick it). The synth’s answer becomes the value reported by #last_assistant_content, so callers (notably theagenttool frompikuri-subagents) still get a usable reply.
Cancellation rescue
If the cancellable: Control::Cancellable trips during Chat#ask, #run_loop catches the Cancelled exception, emits an Event::Cancelled to the listener stream, and re-raises. No synthesizer fallback runs: cancellation means the user asked the agent to drop everything, so salvaging a partial answer would be the wrong move. The caller (typically a REPL) rescues the re-raised exception and returns control to the user; because #run_loop calls Control::Cancellable#reset! at the start of every turn, the same agent instance can take a fresh turn immediately afterwards.
Defined Under Namespace
Modules: Control, Event, Extension, Listener, Synthesizer Classes: ChatTransport, Configurator, ContextWindowDetector, ExtensionContext, ListenerList
Instance Attribute Summary collapse
-
#cancellable ⇒ Control::Cancellable?
readonly
The cancellation control this agent was constructed with, or
nilwhen none. -
#context_window_cap ⇒ Integer?
readonly
Resolved context-window cap — the ChatTransport#context_window if one was given, else what ContextWindowDetector probed.
-
#extensions ⇒ Array<Extension>
readonly
Extension instances bound to this agent — added via Configurator#add_extension inside the
Agent.newblock. -
#id ⇒ String
readonly
This agent’s unique identifier — empty for the main agent; for sub-agents, the persona-rooted id assigned by the
agenttool frompikuri-subagents(e.g. “researcher 0”, “researcher 1”, “file_miner 0”). -
#interloper ⇒ Control::Interloper?
readonly
The mid-loop user-input control this agent was constructed with, or
nilwhen none. -
#step_limit ⇒ Control::StepLimit?
readonly
The step-budget control this agent was constructed with, or
nilwhen none. -
#streaming ⇒ Boolean
readonly
truewhen this agent opted into chunk-level streaming (see thestreaming:kwarg on #initialize);falseotherwise. -
#sub_agent_tools ⇒ Array<Tool>
readonly
Tools registered via Configurator#add_sub_agent_tool, in declaration order.
-
#system_prompt ⇒ String
readonly
System prompt actually sent to the chat — equal to the constructor’s
system_prompt:argument plus any snippets appended via Configurator#append_system_prompt (extensions’ <available_skills> / <available_mcps> / <available_agents>, …). -
#tools ⇒ Array<Tool>
readonly
This agent’s tool list in declaration order.
-
#transport ⇒ ChatTransport
readonly
The resolved transport bundle this agent was constructed with — same model id / provider / assume-model-exists flag passed to every
RubyLLM.chatcall originating from this agent (the main chat, the synthesizer rescue, theagenttool frompikuri-subagents).
Instance Method Summary collapse
-
#close ⇒ void
Release agent-owned resources.
- #initialize(transport:, system_prompt:, step_limit: nil, cancellable: nil, interloper: nil, id: '', streaming: false) {|Configurator| ... } ⇒ Agent constructor
-
#last_assistant_content ⇒ String?
Final assistant message content for the most recent #run_loop.
-
#model ⇒ String
Resolved model id from #transport.
-
#run_loop(user_message:, transport: nil) ⇒ nil
Run the agent loop for a single user turn.
-
#to_s ⇒ String
Short, single-line config dump suitable for a startup banner or a debug print.
Constructor Details
#initialize(transport:, system_prompt:, step_limit: nil, cancellable: nil, interloper: nil, id: '', streaming: false) {|Configurator| ... } ⇒ Agent
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/pikuri/agent.rb', line 152 def initialize(transport:, system_prompt:, step_limit: nil, cancellable: nil, interloper: nil, id: '', streaming: false, &block) @transport = transport.model ? transport : transport.with(model: RubyLLM.config.default_model) @cancellable = cancellable @closed = false @system_prompt = system_prompt @step_limit = step_limit @interloper = interloper @id = id @streaming = streaming @synth_answer = nil @on_close_handlers = [] # Stashed for {#run_configure}, which runs the failure-prone # build phase below out of a separate method. @block = block # Register *before* the build phase so a mid-construction raise # is still recoverable: extensions arm their cleanup via # +c.on_close+ (which writes straight to +@on_close_handlers+, # see {Configurator}), and the rescue below fires whatever was # armed before the failure. On the happy path this registration # is the at-exit backstop if the host forgets {#close}; an # explicit {#close} unregisters, so the agent isn't pinned alive # until process exit. Pikuri::Finalizers.register(self) begin run_configure rescue StandardError # Half-built agent (e.g. an extension's +configure+ raised # Cancelled mid-spawn). Fire the handlers armed so far, drop # out of the registry, and re-raise — no partial state leaks. close raise end end |
Instance Attribute Details
#cancellable ⇒ Control::Cancellable? (readonly)
Returns the cancellation control this agent was constructed with, or nil when none. Read by extensions that propagate cancellation to their own LLM calls (e.g. the agent tool from pikuri-subagents shares it with spawned sub-agents so one Ctrl+C stops the tree).
246 247 248 |
# File 'lib/pikuri/agent.rb', line 246 def cancellable @cancellable end |
#context_window_cap ⇒ Integer? (readonly)
Returns resolved context-window cap — the Pikuri::Agent::ChatTransport#context_window if one was given, else what ContextWindowDetector probed. nil when neither produced a value (a non-llama server with no explicit cap). Re-resolved on every model switch (see #run_loop‘s transport:). Read by extensions that spawn their own ruby_llm calls (notably the agent tool from pikuri-subagents, which hands a sub-agent parent.transport.with(context_window: this) so the resolved cap rides along without a re-probe).
289 290 291 |
# File 'lib/pikuri/agent.rb', line 289 def context_window_cap @context_window_cap end |
#extensions ⇒ Array<Extension> (readonly)
Returns extension instances bound to this agent — added via Pikuri::Agent::Configurator#add_extension inside the Agent.new block. Each instance’s configure runs during the block and its bind runs at the end of #initialize, once per registration (so once per parent agent in the typical setup; sub-agents do not inherit extensions).
278 279 280 |
# File 'lib/pikuri/agent.rb', line 278 def extensions @extensions end |
#id ⇒ String (readonly)
Returns this agent’s unique identifier — empty for the main agent; for sub-agents, the persona-rooted id assigned by the agent tool from pikuri-subagents (e.g. “researcher 0”, “researcher 1”, “file_miner 0”). Propagated to listeners via ListenerList#for_sub_agent(id:) so id-aware ones can tag output. Distinct from the persona’s name (the value the LLM picks in the agent tool’s name: argument).
261 262 263 |
# File 'lib/pikuri/agent.rb', line 261 def id @id end |
#interloper ⇒ Control::Interloper? (readonly)
Returns the mid-loop user-input control this agent was constructed with, or nil when none.
251 252 253 |
# File 'lib/pikuri/agent.rb', line 251 def interloper @interloper end |
#step_limit ⇒ Control::StepLimit? (readonly)
Returns the step-budget control this agent was constructed with, or nil when none.
238 239 240 |
# File 'lib/pikuri/agent.rb', line 238 def step_limit @step_limit end |
#streaming ⇒ Boolean (readonly)
Returns true when this agent opted into chunk-level streaming (see the streaming: kwarg on #initialize); false otherwise. Read by extensions that spawn their own ruby_llm calls (notably the agent tool from pikuri-subagents, so spawned sub-agents inherit the same mode).
269 270 271 |
# File 'lib/pikuri/agent.rb', line 269 def streaming @streaming end |
#sub_agent_tools ⇒ Array<Tool> (readonly)
Returns tools registered via Pikuri::Agent::Configurator#add_sub_agent_tool, in declaration order. Invisible to the parent LLM (never sent to ruby_llm); available only to sub-agents whose persona tool_names match. See Configurator‘s “Two tool pools” header for the trifecta-defense rationale.
219 220 221 |
# File 'lib/pikuri/agent.rb', line 219 def sub_agent_tools @sub_agent_tools end |
#system_prompt ⇒ String (readonly)
Returns system prompt actually sent to the chat —equal to the constructor’s system_prompt: argument plus any snippets appended via Pikuri::Agent::Configurator#append_system_prompt (extensions’ <available_skills> / <available_mcps> / <available_agents>, …). Not inherited by sub-agents —each persona owns its own system prompt verbatim.
234 235 236 |
# File 'lib/pikuri/agent.rb', line 234 def system_prompt @system_prompt end |
#tools ⇒ Array<Tool> (readonly)
Returns this agent’s tool list in declaration order. Read by extensions that filter against it (notably the agent tool from pikuri-subagents, which picks the sub-agent’s toolset from the parent’s instances so any already-bound workspace/confirmer wiring travels along). Tools listed here are also the ones registered with ruby_llm — the parent LLM can call any of them. Compare with #sub_agent_tools.
211 212 213 |
# File 'lib/pikuri/agent.rb', line 211 def tools @tools end |
#transport ⇒ ChatTransport (readonly)
Returns the resolved transport bundle this agent was constructed with — same model id / provider / assume-model-exists flag passed to every RubyLLM.chat call originating from this agent (the main chat, the synthesizer rescue, the agent tool from pikuri-subagents). Read by extensions that need to spawn their own ruby_llm calls (e.g. MCP description synthesis, sub-agent delegation).
201 202 203 |
# File 'lib/pikuri/agent.rb', line 201 def transport @transport end |
Instance Method Details
#close ⇒ void
This method returns an undefined value.
Release agent-owned resources. Fires every handler registered via Pikuri::Agent::Configurator#on_close (during the Agent.new block) and Pikuri::Agent::ExtensionContext#on_close (during Pikuri::Agent::Extension#bind or any later hook), in LIFO order — matches Ruby ensure-block semantics so handlers registered later (which may depend on handlers registered earlier) tear down first. Each handler runs inside its own rescue; an exception is logged via Pikuri.logger_for but doesn’t abort the rest. Idempotent —subsequent calls are no-ops.
411 412 413 414 415 416 417 418 419 420 421 422 423 424 |
# File 'lib/pikuri/agent.rb', line 411 def close return if @closed @closed = true # Drop out of the process-global registry first: a deliberate # close means this agent no longer needs the at-exit fallback, # and removing the reference lets it be garbage-collected. Pikuri::Finalizers.unregister(self) @on_close_handlers.reverse_each do |handler| handler.call rescue StandardError => e LOGGER.warn("on_close handler raised #{e.class}: #{e.}") end end |
#last_assistant_content ⇒ String?
Final assistant message content for the most recent #run_loop. When the synthesizer rescue fired, returns its answer; otherwise walks the underlying chat’s history. Returns nil if neither source has produced an assistant turn yet.
298 299 300 301 302 303 |
# File 'lib/pikuri/agent.rb', line 298 def last_assistant_content return @synth_answer if @synth_answer last = @chat..reverse.find { |m| m.role == :assistant } last&.content end |
#model ⇒ String
Returns resolved model id from #transport. Convenience delegator for callers that don’t need the full transport bundle.
224 225 226 |
# File 'lib/pikuri/agent.rb', line 224 def model @transport.model end |
#run_loop(user_message:, transport: nil) ⇒ nil
Run the agent loop for a single user turn. Emits an Pikuri::Agent::Event::UserTurn with mid_loop: false, resets the step-budget and cancellation controls (so a stale state from a prior turn doesn’t poison this one), and forwards user_message to #chat via ask. Returns nil; rendering and any other observable output is the listeners’ responsibility.
If the step_limit control trips during completion, the rescue branch applies its Pikuri::Agent::Control::StepLimit#on_exhausted policy: :raise re-raises the Exceeded exception to the host (chat history intact — the next turn’s reset! refreshes the budget, so “continue” just works); :synthesize emits an Pikuri::Agent::Event::FallbackNotice and runs the Synthesizer prompt on a nested tools-free agent, capturing its answer for #last_assistant_content. See “Step-exhaustion policy” in the class header.
If the cancellable control trips during ask, the rescue branch emits an Pikuri::Agent::Event::Cancelled and re-raises the Cancelled exception. No synthesizer fallback runs — see the “Cancellation rescue” section in the class header.
Subsequent calls keep building on the same chat history, so the model sees full multi-turn context.
Switching models mid-conversation
Passing a transport: that differs from the current one switches the underlying chat to that model — via Chat#with_model, so the history and the registered callbacks survive — re-resolves the context-window cap, and emits an Pikuri::Agent::Event::ModelSwitched followed by a fresh Pikuri::Agent::Event::ContextCap. The switch is deliberately confined to the top of this method (a private apply_transport!) rather than exposed as a standalone setter: the chat is single-thread-confined, so doing it here serializes the swap with the turn on the loop’s own thread — a background thread mutating with_model‘s connection mid-completion would tear an in-flight stream. A nil transport: (the default) keeps the current model. The conversation is not re-baselined: a switch is the same conversation under a new model, so the message count and running context size carry over (the next turn’s token report self-corrects to the new model’s count).
365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 |
# File 'lib/pikuri/agent.rb', line 365 def run_loop(user_message:, transport: nil) raise ArgumentError, "user_message must not be blank, got #{.inspect}" \ if .nil? || .to_s.strip.empty? apply_transport!(transport) if transport @synth_answer = nil @step_limit&.reset! @cancellable&.reset! # Append the user turn, emit it, then run the memory dispatch — so # any <memory-context> the dispatch injects lands as a :system # message *after* the user turn it annotates (append-only at the # tail; see {#dispatch_ext_on_user_message}). `ask` would bundle the # user-message append with completion atomically, leaving no seam to # inject between them, so the two halves run explicitly here: # add_message + complete (the exact pair `ask` is sugar for). A raw # String content matches the interloper drain path. @chat.(role: :user, content: ) @listeners.emit(Event::UserTurn.new(content: , mid_loop: false)) () if @streaming @chat.complete(&streaming_block) else @chat.complete end nil rescue Control::Cancellable::Cancelled @listeners.emit(Event::Cancelled.new) raise rescue Control::StepLimit::Exceeded => e raise unless @step_limit&.on_exhausted == :synthesize @synth_answer = Synthesizer.run_synthesizer(@extension_context, @chat., ) nil end |
#to_s ⇒ String
Short, single-line config dump suitable for a startup banner or a debug print.
434 435 436 |
# File 'lib/pikuri/agent.rb', line 434 def to_s "Agent(id=#{@id}, model=#{model}, tools=#{@tools.size}, listeners=#{@listeners})" end |