Class: Pikuri::Agent
- Inherits:
-
Object
- Object
- Pikuri::Agent
- 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
-
#chat ⇒ RubyLLM::Chat
readonly
Underlying chat; the extension seam.
-
#context_window_cap ⇒ Integer?
readonly
Context-window cap resolved by ContextWindowDetector at construction time.
-
#listeners ⇒ ListenerList
readonly
The listener list attached to this agent’s chat.
-
#name ⇒ String
readonly
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”).
-
#skill_catalog ⇒ Tool::SkillCatalog
readonly
Catalog passed to the constructor;
Tool::SkillCatalog::EMPTYif none was supplied. -
#system_prompt ⇒ String
readonly
System prompt actually sent to the chat — equal to the constructor’s
system_prompt:argument plus, when a non- emptyskill_catalog:was supplied, the catalog’s <available_skills> block. -
#tools ⇒ Array<Tool>
readonly
This agent’s tool list in declaration order.
-
#transport ⇒ ChatTransport
readonly
The resolved transport bundle this agent was constructed with — same model id / provider / assume-model-exists flag passed to every
RubyLLM.chatcall originating from this agent (the main chat, the synthesizer rescue, the sub-agent tool).
Instance Method Summary collapse
-
#allow_sub_agent(max_steps: 10) ⇒ void
Adds a
sub_agenttool 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). - #initialize(transport:, system_prompt:, tools:, listeners:, context_window: nil, llama_probe_url: nil, name: '', skill_catalog: Tool::SkillCatalog::EMPTY) ⇒ Agent constructor
-
#last_assistant_content ⇒ String?
Final assistant message content for the most recent #run_loop.
-
#model ⇒ String
Resolved model id from #transport.
-
#run_loop(user_message:) ⇒ nil
Run the agent loop for a single user turn.
-
#to_s ⇒ String
Short, single-line config dump suitable for a startup banner or a debug print.
Constructor Details
#initialize(transport:, system_prompt:, tools:, listeners:, context_window: nil, llama_probe_url: nil, name: '', skill_catalog: Tool::SkillCatalog::EMPTY) ⇒ Agent
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
#chat ⇒ RubyLLM::Chat (readonly)
Returns underlying chat; the extension seam.
120 121 122 |
# File 'lib/pikuri/agent.rb', line 120 def chat @chat end |
#context_window_cap ⇒ Integer? (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.
174 175 176 |
# File 'lib/pikuri/agent.rb', line 174 def context_window_cap @context_window_cap end |
#listeners ⇒ ListenerList (readonly)
Returns the listener list attached to this agent’s chat.
158 159 160 |
# File 'lib/pikuri/agent.rb', line 158 def listeners @listeners end |
#name ⇒ String (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.
167 168 169 |
# File 'lib/pikuri/agent.rb', line 167 def name @name end |
#skill_catalog ⇒ Tool::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).
154 155 156 |
# File 'lib/pikuri/agent.rb', line 154 def skill_catalog @skill_catalog end |
#system_prompt ⇒ String (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.
149 150 151 |
# File 'lib/pikuri/agent.rb', line 149 def system_prompt @system_prompt end |
#tools ⇒ Array<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).
135 136 137 |
# File 'lib/pikuri/agent.rb', line 135 def tools @tools end |
#transport ⇒ ChatTransport (readonly)
Returns the resolved transport bundle this agent was constructed with — same model id / provider / assume-model-exists flag passed to every RubyLLM.chat call originating from this agent (the main chat, the synthesizer rescue, the 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.
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_content ⇒ String?
Final assistant message content for the most recent #run_loop. When the synthesizer rescue fired, returns its answer; otherwise walks the underlying chat’s history. Returns nil if neither source has produced an assistant turn yet.
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..reverse.find { |m| m.role == :assistant } last&.content end |
#model ⇒ String
Returns resolved model id from #transport. Convenience delegator for callers that don’t need the full transport bundle.
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.
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 #{.inspect}" \ if .nil? || .to_s.strip.empty? @synth_answer = nil @listeners.(Message::User.new(content: )) @chat.ask() nil rescue Listener::StepLimit::Exceeded => e notice = Message::FallbackNotice.new( reason: "agent exhausted #{e.max_steps} steps; synthesizing answer from gathered evidence" ) @listeners.(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., user_message: , listeners: @listeners.for_sub_agent(max_steps: 1, name: synth_name) ) nil end |
#to_s ⇒ String
Short, single-line config dump suitable for a startup banner or a debug print. Delegates the listener rendering to Pikuri::Agent::ListenerList#to_s.
282 283 284 |
# File 'lib/pikuri/agent.rb', line 282 def to_s "Agent(model=#{model}, tools=#{@tools.size}, listeners=#{@listeners})" end |