Class: Pikuri::Agent

Inherits:
Object
  • Object
show all
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:

  1. Listeners (ListenerList + Listener::Base subclasses) — pure consumers of the event stream; they never write back. The Agent emits 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 an Agent reference to emission — no listeners reader, no chat reader, 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.

  2. Controls (Control::StepLimit, Control::Cancellable, Control::Interloper) — host-facing signal holders. The Agent reads from them at well-defined boundaries: Control::StepLimit#tick! on every before_tool_call (and Control::StepLimit#reset! at turn start), Control::Cancellable#check! on every before_tool_call (and Control::Cancellable#reset! at turn start), Control::Interloper#drain! on every after_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-free Agent (the same construction shape the agent tool from pikuri-subagents uses for sub-agents): parent’s listener stream derived via ListenerList#for_sub_agent (Terminal padded, TokenLog zeroed, recorder shared by reference), parent’s cancellable shared so a user cancel during synthesis still works, a defensive step_limit at 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 the agent tool from pikuri-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

Instance Method Summary collapse

Constructor Details

#initialize(transport:, system_prompt:, step_limit: nil, cancellable: nil, interloper: nil, id: '', streaming: false) {|Configurator| ... } ⇒ Agent

Parameters:

  • transport (ChatTransport)

    the model-resolution bundle (model / provider / assume_model_exists and, for a model on a non-global server, api_base / api_key) the chat is built from. Bundled into one value object so every construction site — this constructor, the synthesizer rescue below, a mid-conversation switch — can forward it with one assignment instead of loose kwargs (where dropping one would silently route the chat elsewhere or raise RubyLLM::ModelNotFoundError). If transport.model is nil, it’s filled in from RubyLLM.config.default_model.

  • system_prompt (String)

    system message prepended to the chat. Extensions append their advertisement blocks (e.g. <available_skills>, <available_mcps>) onto this base via Pikuri::Agent::Configurator#append_system_prompt during the block.

  • step_limit (Control::StepLimit, nil) (defaults to: nil)

    step budget control. When set, Pikuri::Agent::Control::StepLimit#tick! fires on every before_tool_call and Pikuri::Agent::Control::StepLimit#reset! at the start of each turn; the budget’s Pikuri::Agent::Control::StepLimit#on_exhausted policy decides what #run_loop does when it trips (see “Step-exhaustion policy” in the class header). nil means “no step budget” (the agent can loop indefinitely).

  • cancellable (Control::Cancellable, nil) (defaults to: nil)

    cancellation control. When set, Pikuri::Agent::Control::Cancellable#check! fires on every before_tool_call and Pikuri::Agent::Control::Cancellable#reset! at the start of each turn. nil means “not cancellable” (the host has no way to stop a running turn except by killing the process).

  • interloper (Control::Interloper, nil) (defaults to: nil)

    mid-loop user-input queue. When set, the queue is drained at every after_tool_result and each item becomes a Pikuri::Agent::Event::UserTurn with mid_loop: true. nil means “no mid-loop injection” (the bundled CLIs default).

  • id (String) (defaults to: '')

    unique identifier for this agent. Empty for the main agent; sub-agents get persona-rooted ids like “researcher 0”, “researcher 1”, “file_miner 0”, … generated by the agent tool from pikuri-subagents from the persona name + a per-persona counter. Forwarded to listeners through Pikuri::Agent::ListenerList#for_sub_agent so id-aware ones (notably Pikuri::Agent::Listener::TokenLog) can tag their output. The word “id” is deliberate — “name” is reserved throughout the codebase for the persona-name load (the value the LLM picks in the agent tool’s name: argument).

  • streaming (Boolean) (defaults to: false)

    opt into chunk-level streaming. When true, #run_loop passes a per-chunk block to Chat#complete, and ruby_llm requests SSE responses from the provider — chunks are normalized into Pikuri::Agent::Event::ThinkingDelta / Pikuri::Agent::Event::AssistantDelta on the listener stream as they arrive. When false (the default), Chat#ask runs in single-shot mode and only the message-level Pikuri::Agent::Event::Thinking / Pikuri::Agent::Event::Assistant bookends fire from after_message. Read by the agent tool from pikuri-subagents so spawned sub-agents inherit the same mode without an extra kwarg.

Yields:



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

#cancellableControl::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).

Returns:

  • (Control::Cancellable, nil)

    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_capInteger? (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).

Returns:

  • (Integer, nil)

    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

#extensionsArray<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).

Returns:

  • (Array<Extension>)

    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

#idString (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).

Returns:

  • (String)

    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

#interloperControl::Interloper? (readonly)

Returns the mid-loop user-input control this agent was constructed with, or nil when none.

Returns:

  • (Control::Interloper, nil)

    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_limitControl::StepLimit? (readonly)

Returns the step-budget control this agent was constructed with, or nil when none.

Returns:

  • (Control::StepLimit, nil)

    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

#streamingBoolean (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).

Returns:

  • (Boolean)

    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_toolsArray<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.

Returns:

  • (Array<Tool>)

    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_promptString (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.

Returns:

  • (String)

    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

#toolsArray<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.

Returns:

  • (Array<Tool>)

    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

#transportChatTransport (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).

Returns:

  • (ChatTransport)

    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

#closevoid

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.message}")
  end
end

#last_assistant_contentString?

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.

Returns:

  • (String, nil)


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.messages.reverse.find { |m| m.role == :assistant }
  last&.content
end

#modelString

Returns resolved model id from #transport. Convenience delegator for callers that don’t need the full transport bundle.

Returns:

  • (String)

    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).

Parameters:

  • user_message (String)

    the user’s request for this turn; must not be nil, empty, or whitespace-only

  • transport (ChatTransport, nil) (defaults to: nil)

    when non-nil and structurally different from the current transport, switch to it before running the turn (see above); nil keeps the current model

Returns:

  • (nil)

Raises:



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 #{user_message.inspect}" \
    if user_message.nil? || user_message.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.add_message(role: :user, content: user_message)
  @listeners.emit(Event::UserTurn.new(content: user_message, mid_loop: false))
  dispatch_ext_on_user_message(user_message)
  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.messages, user_message)
  nil
end

#to_sString

Short, single-line config dump suitable for a startup banner or a debug print.

Examples:

agent.to_s
# => "Agent(id=, model=qwen3-35b, tools=4, listeners=[Terminal])"

Returns:

  • (String)


434
435
436
# File 'lib/pikuri/agent.rb', line 434

def to_s
  "Agent(id=#{@id}, model=#{model}, tools=#{@tools.size}, listeners=#{@listeners})"
end