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 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, 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, id: '', 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.

  • 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 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 the agent tool from pikuri-subagents so spawned sub-agents inherit the same mode without an extra kwarg.

Yields:



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
# File 'lib/pikuri/agent.rb', line 387

def initialize(transport:, system_prompt:,
               step_limit: nil, cancellable: nil, interloper: nil,
               context_window: nil, llama_probe_url: 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
  @context_window = context_window
  @llama_probe_url = llama_probe_url

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



490
491
492
# File 'lib/pikuri/agent.rb', line 490

def cancellable
  @cancellable
end

#chatRubyLLM::Chat (readonly)

Returns underlying chat; the extension seam.

Returns:

  • (RubyLLM::Chat)

    underlying chat; the extension seam



431
432
433
# File 'lib/pikuri/agent.rb', line 431

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 extensions that spawn their own ruby_llm calls (notably the agent tool from pikuri-subagents, 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 extensions that spawn their own ruby_llm calls (notably the agent tool from pikuri-subagents, so spawned sub-agents inherit the same cap without re-probing).



531
532
533
# File 'lib/pikuri/agent.rb', line 531

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



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

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



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

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.



495
496
497
# File 'lib/pikuri/agent.rb', line 495

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



478
479
480
# File 'lib/pikuri/agent.rb', line 478

def listeners
  @listeners
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.



482
483
484
# File 'lib/pikuri/agent.rb', line 482

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



513
514
515
# File 'lib/pikuri/agent.rb', line 513

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.



459
460
461
# File 'lib/pikuri/agent.rb', line 459

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.



474
475
476
# File 'lib/pikuri/agent.rb', line 474

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.



451
452
453
# File 'lib/pikuri/agent.rb', line 451

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



441
442
443
# File 'lib/pikuri/agent.rb', line 441

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 &



159
160
161
162
163
164
# File 'lib/pikuri/agent.rb', line 159

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



300
301
302
303
304
305
306
307
308
309
310
# File 'lib/pikuri/agent.rb', line 300

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, on_user_message: 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:



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/pikuri/agent.rb', line 105

def self.wire_chat(chat, listeners:, step_limit: nil, cancellable: nil, interloper: nil,
                   on_user_message: 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, on_user_message) 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.



649
650
651
652
653
654
655
656
657
658
659
660
661
662
# File 'lib/pikuri/agent.rb', line 649

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

#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. Sub-agents (the agent tool from pikuri-subagents) 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



700
701
702
# File 'lib/pikuri/agent.rb', line 700

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)


540
541
542
543
544
545
# File 'lib/pikuri/agent.rb', line 540

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.



464
465
466
# File 'lib/pikuri/agent.rb', line 464

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)


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

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:



578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
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
# File 'lib/pikuri/agent.rb', line 578

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
  @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(&self.class.streaming_block(listeners: @listeners, cancellable: @cancellable))
  else
    @chat.complete
  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 id — same +_+
  # separator the sub-agent generator uses, so main becomes
  # +"synthesizer"+ and a sub-agent +"researcher 0"+ becomes
  # +"researcher 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_id = @id.empty? ? 'synthesizer' : "#{@id}_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(id: synth_id),
    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(id=, model=qwen3-35b, tools=4, listeners=[Terminal])"

Returns:

  • (String)


712
713
714
# File 'lib/pikuri/agent.rb', line 712

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