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/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. The Agent is the sole emitter; listeners never write back. New rendering or capture targets (a web sink, a structured log) are added here 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.

Step-exhaustion rescue

If the step_limit: Control::StepLimit trips during Chat#ask, #run_loop catches the Exceeded exception, emits an Event::FallbackNotice to the listener stream, and hands off to Synthesizer.run on a fresh RubyLLM::Chat. The synth reuses the parent’s listener stream via ListenerList#for_sub_agent (Terminal padded, TokenLog zeroed, recorder shared by reference) with a name: derived from the parent’s. The synth shares the parent’s cancellable so a user cancel during synthesis still works, and gets a fresh step_limit at max: 1 (defensive — the synth has no tools and shouldn’t trip it). The synth’s answer becomes the value reported by #last_assistant_content, so callers (notably Tool::SubAgent) 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, ListenerList

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

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

Parameters:

  • transport (ChatTransport)

    the model-resolution triple (model / provider / assume_model_exists) forwarded to RubyLLM.chat. Bundled into one value object so every construction site — this constructor and the synthesizer rescue below — can forward all three with one assignment instead of three 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. 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).

  • context_window (Integer, nil) (defaults to: nil)

    explicit override for the model’s context-window cap. When set, it wins over ruby_llm’s reported value and the llama.cpp probe — see ContextWindowDetector for precedence. Resolved cap is emitted as an Pikuri::Agent::Event::ContextCap immediately after construction.

  • llama_probe_url (String, nil) (defaults to: nil)

    llama.cpp /props URL used as the third detection source. Only consulted when neither context_window nor ruby_llm’s reported value is set. Typically derived by bin/pikuri-chat from its configured openai_api_base; leave nil when the configured server is anything other than llama.cpp.

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

    identifier for this agent. Empty for the main agent; sub-agents get monotonic hierarchical names like “sub_agent 0”, “sub_agent 1”, “sub_agent 0_0”, … generated by Tool::SubAgent from the parent’s name + a per-parent counter. Forwarded to listeners through Pikuri::Agent::ListenerList#for_sub_agent so name- aware ones (notably Pikuri::Agent::Listener::TokenLog) can tag their output.

  • streaming (Boolean) (defaults to: false)

    opt into chunk-level streaming. When true, #run_loop passes the block returned by streaming_block to Chat#ask, 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 Tool::SubAgent so spawned sub-agents inherit the same mode without an extra kwarg.

Yields:



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
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
# File 'lib/pikuri/agent.rb', line 367

def initialize(transport:, system_prompt:,
               step_limit: nil, cancellable: nil, interloper: nil,
               context_window: nil, llama_probe_url: nil, name: '',
               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
  @name = name
  @streaming = streaming
  @synth_answer = nil
  @on_close_handlers = []

  # Single Configurator funnel for everything the block adds —
  # tools, listeners, system-prompt snippets, extensions
  # (both newly-configured via #add_extension and inherited
  # via #inherit_extensions for sub-agents), on_close handlers,
  # and the sub-agent request. See IDEAS.md §"Extension protocol
  # design".
  configurator = Configurator.new(
    transport: @transport,
    system_prompt_base: system_prompt,
    name: @name,
    streaming: @streaming,
    step_limit: @step_limit,
    cancellable: @cancellable,
    interloper: @interloper
  )

  block&.call(configurator)

  @tools = configurator.tools.dup
  @listeners = ListenerList.new(configurator.listeners)
  configurator.system_prompt_additions.each do |snippet|
    @system_prompt = "#{@system_prompt}\n\n#{snippet}"
  end
  @on_close_handlers.concat(configurator.on_close_handlers)
  @extensions = configurator.extensions.dup

  @chat = RubyLLM.chat(**@transport.to_h)
  @chat.with_instructions(@system_prompt)
  @tools.each { |t| @chat.with_tool(t.to_ruby_llm_tool) }

  @context_window_cap = ContextWindowDetector.new(
    override: context_window,
    ruby_llm_reported: @chat.model.context_window,
    llama_probe_url: llama_probe_url
  ).detect

  self.class.wire_chat(
    @chat,
    listeners: @listeners,
    step_limit: @step_limit,
    cancellable: @cancellable,
    interloper: @interloper
  )

  # One-shot context-window cap: lets every listener that
  # cares (notably TokenLog) pick the value off the stream
  # before any Tokens event arrives.
  @listeners.emit(Event::ContextCap.new(cap: @context_window_cap))

  # Sub-agent tool: constructed *after* @tools is final and
  # @context_window_cap is set, so its snapshot of the parent's
  # tool list doesn't include itself (recursion guard) and the
  # cap can be threaded through to spawned sub-agents. The new
  # +Tool::SubAgent+ instance is appended to both +@tools+ and
  # +@chat+, so sub-agents inheriting via the snapshot still
  # get the surrounding tool set but never the +sub_agent+ tool
  # itself. See {Configurator#allow_sub_agent}.
  if configurator.sub_agent_request
    if @tools.any?(Tool::SubAgent)
      raise 'Tool::SubAgent must not be added via c.add_tool when c.allow_sub_agent ' \
            'is used; Agent auto-registers it from the Configurator request.'
    end

    sub_tool = Tool::SubAgent.new(self, max_steps: configurator.sub_agent_request.max_steps)
    @tools << sub_tool
    @chat.with_tool(sub_tool.to_ruby_llm_tool)
  end

  # Bind sweep — each extension gets its chance to install
  # per-agent state (dynamic tools via #internal_add_tool,
  # per-agent close hooks via #on_close, etc.) now that the
  # chat is fully wired. See IDEAS.md §"Extension protocol
  # design" for what #configure vs #bind are each for.
  @extensions.each { |ext| ext.bind(self) }

  # Fallback cleanup: if the host forgets to call #close, the
  # at_exit hook fires it on process exit. Idempotent, so an
  # explicit close earlier makes this a no-op. The closure
  # captures self, which keeps the agent reachable until
  # process exit — fine for the handful of agents a typical
  # host creates; if pikuri grows a long-running host that
  # constructs many short-lived agents, switch to a single
  # process-global registry that close-then-removes.
  at_exit { close }
end

Instance Attribute Details

#cancellableControl::Cancellable? (readonly)

Returns the cancellation control this agent was constructed with, or nil when none. Read by Tool::SubAgent so spawned sub-agents share the same instance.

Returns:

  • (Control::Cancellable, nil)

    the cancellation control this agent was constructed with, or nil when none. Read by Tool::SubAgent so spawned sub-agents share the same instance.



521
522
523
# File 'lib/pikuri/agent.rb', line 521

def cancellable
  @cancellable
end

#chatRubyLLM::Chat (readonly)

Returns underlying chat; the extension seam.

Returns:

  • (RubyLLM::Chat)

    underlying chat; the extension seam



471
472
473
# File 'lib/pikuri/agent.rb', line 471

def chat
  @chat
end

#context_window_capInteger? (readonly)

Returns context-window cap resolved by ContextWindowDetector at construction time. nil when no source produced a value (custom local model with no override and no reachable llama.cpp /props). Read by Tool::SubAgent so spawned sub-agents inherit the same cap without re-probing.

Returns:

  • (Integer, nil)

    context-window cap resolved by ContextWindowDetector at construction time. nil when no source produced a value (custom local model with no override and no reachable llama.cpp /props). Read by Tool::SubAgent so spawned sub-agents inherit the same cap without re-probing.



561
562
563
# File 'lib/pikuri/agent.rb', line 561

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 (new — runs configure now and binds later) or Pikuri::Agent::Configurator#inherit_extensions (sub-agent inheritance — skips configure, just binds), both inside the Agent.new block. Read by Tool::SubAgent so spawned sub-agents inherit the parent’s extension list and re-bind them via the bind sweep.

Returns:



553
554
555
# File 'lib/pikuri/agent.rb', line 553

def extensions
  @extensions
end

#interloperControl::Interloper? (readonly)

Returns the mid-loop user-input control this agent was constructed with, or nil when none. Not propagated to sub-agents — see Pikuri::Agent::Control::Interloper#for_sub_agent.

Returns:



527
528
529
# File 'lib/pikuri/agent.rb', line 527

def interloper
  @interloper
end

#listenersListenerList (readonly)

Returns the listener list attached to this agent’s chat.

Returns:

  • (ListenerList)

    the listener list attached to this agent’s chat



509
510
511
# File 'lib/pikuri/agent.rb', line 509

def listeners
  @listeners
end

#nameString (readonly)

Returns this agent’s identifier — empty for the main agent; for sub-agents, the hierarchical id assigned by Tool::SubAgent (e.g. “sub_agent 0”, “sub_agent 1”, “sub_agent 0_0”). Read by the sub-agent tool so spawned sub-agents prefix their own names with this one, and propagated to listeners via Pikuri::Agent::ListenerList#for_sub_agent so name-aware ones can tag output.

Returns:

  • (String)

    this agent’s identifier — empty for the main agent; for sub-agents, the hierarchical id assigned by Tool::SubAgent (e.g. “sub_agent 0”, “sub_agent 1”, “sub_agent 0_0”). Read by the sub-agent tool so spawned sub-agents prefix their own names with this one, and propagated to listeners via Pikuri::Agent::ListenerList#for_sub_agent so name-aware ones can tag output.



537
538
539
# File 'lib/pikuri/agent.rb', line 537

def name
  @name
end

#step_limitControl::StepLimit? (readonly)

Returns the step-budget control this agent was constructed with, or nil when none. Read by Tool::SubAgent so spawned sub-agents derive their own.

Returns:

  • (Control::StepLimit, nil)

    the step-budget control this agent was constructed with, or nil when none. Read by Tool::SubAgent so spawned sub-agents derive their own.



515
516
517
# File 'lib/pikuri/agent.rb', line 515

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 Tool::SubAgent 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 Tool::SubAgent so spawned sub-agents inherit the same mode.



544
545
546
# File 'lib/pikuri/agent.rb', line 544

def streaming
  @streaming
end

#system_promptString (readonly)

Returns system prompt actually sent to the chat —equal to the constructor’s system_prompt: argument plus any snippets appended by extensions during Pikuri::Agent::Configurator#append_system_prompt (Skills’ <available_skills>, MCP’s <available_mcps>, …). Tool::SubAgent forwards this already-augmented value to spawned sub-agents so they see the same advertisements without re-running extension configure.

Returns:

  • (String)

    system prompt actually sent to the chat —equal to the constructor’s system_prompt: argument plus any snippets appended by extensions during Pikuri::Agent::Configurator#append_system_prompt (Skills’ <available_skills>, MCP’s <available_mcps>, …). Tool::SubAgent forwards this already-augmented value to spawned sub-agents so they see the same advertisements without re-running extension configure.



505
506
507
# File 'lib/pikuri/agent.rb', line 505

def system_prompt
  @system_prompt
end

#toolsArray<Tool> (readonly)

Returns this agent’s tool list in declaration order. Snapshotted by Tool::SubAgent so spawned sub-agents inherit the parent’s tools (minus the sub-agent tool itself, which #allow_sub_agent appends to @tools only after the snapshot has been taken —recursion guard).

Returns:

  • (Array<Tool>)

    this agent’s tool list in declaration order. Snapshotted by Tool::SubAgent so spawned sub-agents inherit the parent’s tools (minus the sub-agent tool itself, which #allow_sub_agent appends to @tools only after the snapshot has been taken —recursion guard).



488
489
490
# File 'lib/pikuri/agent.rb', line 488

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 sub-agent tool). Read by Tool::SubAgent so spawned sub-agents reuse the same transport.

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 sub-agent tool). Read by Tool::SubAgent so spawned sub-agents reuse the same transport.



480
481
482
# File 'lib/pikuri/agent.rb', line 480

def transport
  @transport
end

Class Method Details

.streaming_block(listeners:, cancellable: nil) ⇒ Proc

Build the per-chunk streaming block passed to Chat#ask. Each invocation of the returned proc converts one RubyLLM::Chunk into zero, one, or two delta events (Pikuri::Agent::Event::ThinkingDelta / Pikuri::Agent::Event::AssistantDelta) on listeners. Tool-call chunks are intentionally ignored —partial JSON has no useful rendering; the assembled tool_calls surface through Pikuri::Agent::Event::ToolCall once the message completes.

Lives parallel to wire_chat (instead of being folded into it) because Chat#ask takes the streaming block as an argument rather than a registered callback, so both #run_loop and Pikuri::Agent::Synthesizer.run pass it inline at the call site with &Agent.streaming_block(listeners: …, cancellable: …).

Cancellation polling

When cancellable is non-nil, Pikuri::Agent::Control::Cancellable#check! fires before each chunk’s emit. The before_tool_call wiring in wire_chat only fires when the model requests a tool, which leaves a no-tool turn (e.g. a plain greeting) with zero cancellation points — Ctrl+C trips the flag but nothing reads it. Polling on every streamed chunk closes that gap: an in-flight Cancellation+check! raises on the next chunk delivered after the flag flips, the exception propagates out through ruby_llm’s streaming path (+Chat#ask+ doesn’t rescue), and #run_loop catches it, emits Pikuri::Agent::Event::Cancelled, and re-raises. The pre-emit ordering is deliberate: a chunk that arrives after a cancel request shouldn’t render — the user has said stop.

Parameters:

Returns:

  • (Proc)

    a -> (chunk) { … } proc suitable for passing to Chat#ask with &



151
152
153
154
155
156
# File 'lib/pikuri/agent.rb', line 151

def self.streaming_block(listeners:, cancellable: nil)
  ->(chunk) {
    cancellable&.check!
    emit_chunk(chunk, listeners)
  }
end

.think(transport:, prompt:, cancellable: nil) ⇒ String

One-shot inference. Builds a fresh RubyLLM::Chat with no tools, no MCP, no listeners, no step budget, asks prompt as the single user turn, and returns the assistant’s reply as a plain String. Lives parallel to #initialize / #run_loop because the use case (e.g. summarizing an MCP server’s tool set into a short description block before any agent turn runs) is genuinely one-shot — there is no loop, no tool iteration, no listener stream.

prompt is sent as the user message. For a one-shot call there is no behavioral difference between the system slot and the user slot, so we use one parameter; pack any “instructions + data” framing into prompt directly.

Cancellation

Pikuri::Agent::Control::Cancellable#check! fires once before the call and once after, so a flag flipped right around the request raises Pikuri::Agent::Control::Cancellable::Cancelled promptly. The in-flight HTTP call itself is not interrupted — same “gentle cancel” semantic the main loop offers (see Pikuri::Agent::Control::Cancellable‘s class header). For 30s synthesis passes at boot this is still a useful escape hatch: the next check raises and the call returns.

Failure

Errors from the provider (HTTP failure, malformed response, RubyLLM raising) propagate to the caller verbatim — there is no recovery layer here. Callers that want “fail soft on synthesis errors” (e.g. Mcp::Servers) rescue at their level and fall back to a default; this method stays loud.

Parameters:

Returns:

  • (String)

    the assistant’s reply content

Raises:

  • (ArgumentError)

    when prompt is nil, empty, or whitespace-only

  • (Control::Cancellable::Cancelled)

    when the cancellable flag was tripped at the pre-call check



281
282
283
284
285
286
287
288
289
290
291
# File 'lib/pikuri/agent.rb', line 281

def self.think(transport:, prompt:, cancellable: nil)
  raise ArgumentError, "prompt must not be blank, got #{prompt.inspect}" \
    if prompt.nil? || prompt.to_s.strip.empty?

  transport = transport.with(model: RubyLLM.config.default_model) unless transport.model
  cancellable&.check!
  chat = RubyLLM.chat(**transport.to_h)
  chat.ask(prompt)
  last = chat.messages.reverse.find { |m| m.role == :assistant }
  last&.content.to_s
end

.wire_chat(chat, listeners:, step_limit: nil, cancellable: nil, interloper: nil) ⇒ void

This method returns an undefined value.

Wire one RubyLLM::Chat for pikuri’s event stream and controls. Used by both #initialize (on the main chat) and Pikuri::Agent::Synthesizer.run (on the synth chat) so the two share one source of truth for “which callback emits which event.”

Handles the three message-level registered callbacks (after_message, before_tool_call, after_tool_result); the per-chunk streaming callback is separate because ruby_llm takes it as a block to Chat#ask rather than a registered hook — see streaming_block.

Parameters:



98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/pikuri/agent.rb', line 98

def self.wire_chat(chat, listeners:, step_limit: nil, cancellable: nil, interloper: nil)
  chat.after_message do |msg|
    emit_after_message(msg, listeners)
  end
  chat.before_tool_call do |tc|
    listeners.emit(Event::ToolCall.new(name: tc.name, arguments: tc.arguments))
    step_limit&.tick!
    cancellable&.check!
  end
  chat.after_tool_result do |result|
    listeners.emit(Event::ToolResult.new(content: result))
    drain_interloper(interloper, chat, listeners) if interloper
  end
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 #on_close (during Pikuri::Agent::Extension#bind or any post-construction call), 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.



669
670
671
672
673
674
675
676
677
678
# File 'lib/pikuri/agent.rb', line 669

def close
  return if @closed

  @closed = true
  @on_close_handlers.reverse_each do |handler|
    handler.call
  rescue StandardError => e
    LOGGER.warn("on_close handler raised #{e.class}: #{e.message}")
  end
end

#internal_add_tool(ruby_llm_tool) ⇒ void

This method returns an undefined value.

Register a raw RubyLLM::Tool subclass on this agent’s underlying chat, bypassing the Tool strict-validation seam. Sole intended caller: Mcp::Servers::Connect, which uses this to lazy-add MCP-exposed tools after the LLM invokes mcp_connect in a turn.

The internal_ prefix is the warning: native pikuri tools should go through Tool so they get Tool::Parameters validation and the LLM-actionable “Error: …” contract. MCP tools deliberately don’t — see IDEAS.md §“v1 implementation shape” / “MCP tools bypass Pikuri::Tool entirely.”

The added tool does NOT enter @tools, only @chat‘s tool list. Tool::SubAgent therefore cannot snapshot it (which is the whole point — activation is strictly per-agent, see IDEAS.md §“Per-agent activation, no propagation”).

Parameters:

  • ruby_llm_tool (Class)

    subclass of RubyLLM::Tool



715
716
717
# File 'lib/pikuri/agent.rb', line 715

def internal_add_tool(ruby_llm_tool)
  @chat.with_tool(ruby_llm_tool)
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)


570
571
572
573
574
575
# File 'lib/pikuri/agent.rb', line 570

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.



493
494
495
# File 'lib/pikuri/agent.rb', line 493

def model
  @transport.model
end

#on_close { ... } ⇒ void

This method returns an undefined value.

Register a handler called by #close. Symmetric to Pikuri::Agent::Configurator#on_close — same LIFO + per-handler-rescue + idempotent semantics — but available post-construction, so an Extension‘s bind(agent) can install per-agent cleanup that’s keyed to this specific agent rather than the parent.

Yields:

  • called with no arguments at close time

Raises:

  • (ArgumentError)


688
689
690
691
692
693
# File 'lib/pikuri/agent.rb', line 688

def on_close(&blk)
  raise ArgumentError, 'on_close requires a block' unless block_given?

  @on_close_handlers << blk
  nil
end

#run_loop(user_message:) ⇒ 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 ask, the rescue branch emits an Pikuri::Agent::Event::FallbackNotice and runs Pikuri::Agent::Synthesizer.run on a fresh RubyLLM::Chat. The synth’s answer is captured for #last_assistant_content; the exception does not bubble out.

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.

Parameters:

  • user_message (String)

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

Returns:

  • (nil)

Raises:



608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
# File 'lib/pikuri/agent.rb', line 608

def run_loop(user_message:)
  raise ArgumentError, "user_message must not be blank, got #{user_message.inspect}" \
    if user_message.nil? || user_message.to_s.strip.empty?

  @synth_answer = nil
  @listeners.emit(Event::UserTurn.new(content: user_message, mid_loop: false))
  @step_limit&.reset!
  @cancellable&.reset!
  if @streaming
    @chat.ask(user_message, &self.class.streaming_block(listeners: @listeners, cancellable: @cancellable))
  else
    @chat.ask(user_message)
  end
  nil
rescue Control::Cancellable::Cancelled
  @listeners.emit(Event::Cancelled.new)
  raise
rescue Control::StepLimit::Exceeded => e
  @listeners.emit(Event::FallbackNotice.new(
                    reason: "agent exhausted #{e.max_steps} steps; synthesizing answer from gathered evidence"
                  ))

  # Synth runs under this agent's identity but on a fresh
  # chat with a different system prompt, so it gets a
  # distinct +_synthesizer+ suffix on the name — same +_+
  # separator the sub-agent generator uses, so main becomes
  # +"synthesizer"+ and a sub-agent +"sub_agent 0"+ becomes
  # +"sub_agent 0_synthesizer"+. Any +TokenLog+ in the list
  # tags the synth's prompt under that bracket so it's
  # obvious from the log which turns were the rescue rather
  # than the original loop.
  synth_name = @name.empty? ? 'synthesizer' : "#{@name}_synthesizer"
  synth_chat = RubyLLM.chat(**@transport.to_h)
  # Defensive step limit on the synth: the synth has no
  # tools so it should never trip +before_tool_call+, but
  # guarding the budget anyway means a buggy provider that
  # somehow returns a tool call doesn't loop forever.
  synth_step_limit = @step_limit && Control::StepLimit.new(max: 1)
  @synth_answer = Synthesizer.run(
    chat: synth_chat,
    parent_messages: @chat.messages,
    user_message: user_message,
    listeners: @listeners.for_sub_agent(name: synth_name),
    step_limit: synth_step_limit,
    cancellable: @cancellable,
    streaming: @streaming
  )
  nil
end

#to_sString

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

Examples:

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

Returns:

  • (String)


727
728
729
# File 'lib/pikuri/agent.rb', line 727

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