Class: Pikuri::Agent

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/agent.rb,
lib/pikuri/agent/tokens.rb,
lib/pikuri/agent/message.rb,
lib/pikuri/agent/synthesizer.rb,
lib/pikuri/agent/listener_list.rb,
lib/pikuri/agent/chat_transport.rb,
lib/pikuri/agent/listener/terminal.rb,
lib/pikuri/agent/listener/token_log.rb,
lib/pikuri/agent/listener/step_limit.rb,
lib/pikuri/agent/context_window_detector.rb,
lib/pikuri/agent/listener/message_listener.rb,
lib/pikuri/agent/listener/in_memory_message_list.rb

Overview

Thin wrapper around RubyLLM::Chat: pikuri owns the *extension surface* (the listener objects that consume normalized chat events) while ruby_llm owns the loop itself. The Thought / Tool-call / Observation iteration lives in Chat#complete; pikuri’s job is just attaching listeners at construction time, forwarding the user turn, and notifying the listeners of the new Message::User so any that care about turn boundaries (notably Listener::StepLimit) can react.

Listeners live in a ListenerList the caller supplies — duck-typed against a tiny attach(chat) / on_message(msg) protocol, with the list itself implementing the same protocol so Agent never touches the underlying Array. There are no defaults for tools: or listeners: on #initialize: both are conscious decisions the caller must state every time.

Step-exhaustion rescue

If a Listener::StepLimit in #listeners trips during Chat#ask, #run_loop catches the Exceeded exception, emits a Message::FallbackNotice to every listener, and hands off to Synthesizer.run on a fresh RubyLLM::Chat. The synth reuses the parent’s ListenerList via ListenerList#for_sub_agent with max_steps: 1 — same transformation a sub-agent invocation gets, since the synth is a fresh context: TokenLog zeroed, Terminal padded, StepLimit at the defensive cap (the synth has no tools so it should never trip), InMemoryMessageList shared by reference. The listener name: becomes “<@name>_synthesizer” (or just “synthesizer” for the main agent) so the synth turn is distinct from the parent’s normal turns in any name-aware log line. The synth’s answer becomes the value reported by #last_assistant_content, so callers (notably Tool::SubAgent) still get a usable reply instead of raising past bin/pikuri-chat.

Defined Under Namespace

Modules: Listener, Message, Synthesizer Classes: ChatTransport, ContextWindowDetector, ListenerList, Tokens

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(transport:, system_prompt:, tools:, listeners:, context_window: nil, llama_probe_url: nil, name: '', skill_catalog: Tool::SkillCatalog::EMPTY) ⇒ 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

  • tools (Array<Tool>)

    pikuri tools registered with the underlying chat in declaration order. Each is converted to ruby_llm’s runtime shape via Tool#to_ruby_llm_tool when wired in. Required — no default, because the tool set is a deliberate per-call decision (pass [] for a tools-free agent).

  • listeners (ListenerList)

    the listener list whose attach the constructor calls on the underlying chat. Required — no default, because the renderer and step-budget choices are deliberate per-call decisions. Typical CLI shape: ListenerList.new([Listener::Terminal.new, Listener::StepLimit.new(max: 20)]).

  • 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 pushed to every Pikuri::Agent::Listener::TokenLog so the ctx=<used>/<cap> headline lights up.

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

  • skill_catalog (Tool::SkillCatalog) (defaults to: Tool::SkillCatalog::EMPTY)

    catalog of on-disk skills the agent may load on demand. Defaults to Tool::SkillCatalog::EMPTY, which is a no-op singleton. When non-empty: the catalog’s prompt block (Tool::SkillCatalog#format_for_prompt) is appended to system_prompt so the LLM can see what’s available, and a Tool::Skill bound to the catalog is appended to tools so the LLM can actually load them. The two changes are coupled —advertising skills without a loader (or vice versa) would be a bug, so the catalog is the single source of truth for both.



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/pikuri/agent.rb', line 87

def initialize(transport:, system_prompt:, tools:, listeners:,
               context_window: nil, llama_probe_url: nil, name: '',
               skill_catalog: Tool::SkillCatalog::EMPTY)
  @transport = transport.model ? transport : transport.with(model: RubyLLM.config.default_model)
  @system_prompt = skill_catalog.empty? ? system_prompt : system_prompt + skill_catalog.format_for_prompt
  @skill_catalog = skill_catalog
  @tools = tools.dup
  @listeners = listeners
  @name = name
  @synth_answer = nil

  unless skill_catalog.empty?
    raise 'Tool::Skill cannot be passed in tools: when skill_catalog is non-empty; ' \
          'Agent auto-registers it from the catalog.' \
      if @tools.any?(Tool::Skill)

    @tools << Tool::Skill.new(catalog: skill_catalog)
  end

  @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
  @listeners.context_window_cap = @context_window_cap
  @listeners.attach(@chat)
end

Instance Attribute Details

#chatRubyLLM::Chat (readonly)

Returns underlying chat; the extension seam.

Returns:

  • (RubyLLM::Chat)

    underlying chat; the extension seam



120
121
122
# File 'lib/pikuri/agent.rb', line 120

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.



174
175
176
# File 'lib/pikuri/agent.rb', line 174

def context_window_cap
  @context_window_cap
end

#listenersListenerList (readonly)

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

Returns:

  • (ListenerList)

    the listener list attached to this agent’s chat



158
159
160
# File 'lib/pikuri/agent.rb', line 158

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.



167
168
169
# File 'lib/pikuri/agent.rb', line 167

def name
  @name
end

#skill_catalogTool::SkillCatalog (readonly)

Returns catalog passed to the constructor; Tool::SkillCatalog::EMPTY if none was supplied. Read by callers that want to inspect the loaded skills (e.g. for a startup banner).

Returns:

  • (Tool::SkillCatalog)

    catalog passed to the constructor; Tool::SkillCatalog::EMPTY if none was supplied. Read by callers that want to inspect the loaded skills (e.g. for a startup banner).



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

def skill_catalog
  @skill_catalog
end

#system_promptString (readonly)

Returns system prompt actually sent to the chat — equal to the constructor’s system_prompt: argument plus, when a non- empty skill_catalog: was supplied, the catalog’s <available_skills> block. Tool::SubAgent forwards this already-augmented value to spawned sub-agents, so they see the same catalog without needing the skill_catalog: kwarg themselves.

Returns:

  • (String)

    system prompt actually sent to the chat — equal to the constructor’s system_prompt: argument plus, when a non- empty skill_catalog: was supplied, the catalog’s <available_skills> block. Tool::SubAgent forwards this already-augmented value to spawned sub-agents, so they see the same catalog without needing the skill_catalog: kwarg themselves.



149
150
151
# File 'lib/pikuri/agent.rb', line 149

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



135
136
137
# File 'lib/pikuri/agent.rb', line 135

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.



128
129
130
# File 'lib/pikuri/agent.rb', line 128

def transport
  @transport
end

Instance Method Details

#allow_sub_agent(max_steps: 10) ⇒ void

This method returns an undefined value.

Adds a sub_agent tool that lets this agent spawn sub-agents which share the parent’s model, system prompt, and current tool set (minus the sub-agent tool itself, so recursion is impossible).

Tool::SubAgent snapshots @tools during construction; we append the new sub-agent tool to @tools only after that, so the sub-agent’s tool list never contains itself.

Each sub-agent run gets a derived ListenerList via Pikuri::Agent::ListenerList#for_sub_agent — listeners that define a sub-agent variant return a fresh instance (e.g. StepLimit at the new cap, Terminal with sub-agent padding, TokenLog zeroed); listeners without the hook (InMemoryMessageList, …) are shared by reference so the sub-agent’s events render and capture continuously with the parent’s.

Parameters:

Raises:

  • (RuntimeError)

    if a Tool::SubAgent is already registered on this agent — calling twice would advertise two identically named tools to ruby_llm and double the sub-agent’s tool list (the second snapshot would contain the first sub-agent tool).



265
266
267
268
269
270
271
272
# File 'lib/pikuri/agent.rb', line 265

def allow_sub_agent(max_steps: 10)
  raise "Tool::SubAgent already registered on this agent; allow_sub_agent may only be called once" \
    if @tools.any?(Tool::SubAgent)

  sub_tool = Tool::SubAgent.new(self, max_steps: max_steps)
  @tools << sub_tool
  @chat.with_tool(sub_tool.to_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)


182
183
184
185
186
187
# File 'lib/pikuri/agent.rb', line 182

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.



139
140
141
# File 'lib/pikuri/agent.rb', line 139

def model
  @transport.model
end

#run_loop(user_message:) ⇒ nil

Run the agent loop for a single user turn. Notifies every listener of the Pikuri::Agent::Message::User — which is also how Pikuri::Agent::Listener::StepLimit learns to reset its counter — and forwards user_message to #chat via ask. Returns nil; rendering and any other observable output is the listeners’ responsibility.

If a Pikuri::Agent::Listener::StepLimit trips during ask, the rescue branch emits a Pikuri::Agent::Message::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.

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:

  • (ArgumentError)

    if user_message is nil, empty, or contains only whitespace — an empty turn would poison the chat history and burn a step budget on nothing



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/pikuri/agent.rb', line 209

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.on_message(Message::User.new(content: user_message))
  @chat.ask(user_message)
  nil
rescue Listener::StepLimit::Exceeded => e
  notice = Message::FallbackNotice.new(
    reason: "agent exhausted #{e.max_steps} steps; synthesizing answer from gathered evidence"
  )
  @listeners.on_message(notice)

  synth_chat = RubyLLM.chat(**@transport.to_h)
  # 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_answer = Synthesizer.run(
    chat: synth_chat,
    parent_messages: @chat.messages,
    user_message: user_message,
    listeners: @listeners.for_sub_agent(max_steps: 1, name: synth_name)
  )
  nil
end

#to_sString

Short, single-line config dump suitable for a startup banner or a debug print. Delegates the listener rendering to Pikuri::Agent::ListenerList#to_s.

Examples:

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

Returns:

  • (String)


282
283
284
# File 'lib/pikuri/agent.rb', line 282

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