Class: Pikuri::Agent
- Inherits:
-
Object
- Object
- Pikuri::Agent
- Defined in:
- lib/pikuri/agent.rb,
lib/pikuri/agent/event.rb,
lib/pikuri/agent/control.rb,
lib/pikuri/agent/history.rb,
lib/pikuri/agent/listener.rb,
lib/pikuri/agent/extension.rb,
lib/pikuri/agent/synthesizer.rb,
lib/pikuri/agent/configurator.rb,
lib/pikuri/agent/listener_list.rb,
lib/pikuri/agent/chat_transport.rb,
lib/pikuri/agent/extension_context.rb,
lib/pikuri/agent/listener/terminal.rb,
lib/pikuri/agent/control/interloper.rb,
lib/pikuri/agent/control/step_limit.rb,
lib/pikuri/agent/listener/token_log.rb,
lib/pikuri/agent/control/cancellable.rb,
lib/pikuri/agent/listener/rate_limited.rb,
lib/pikuri/agent/context_window_detector.rb,
lib/pikuri/agent/listener/in_memory_event_list.rb
Overview
Thin wrapper around RubyLLM::Chat: ruby_llm owns the Thought /
Tool-call / Observation loop (+Chat#complete+); pikuri owns the
extension surface — it wires ruby_llm's three callbacks at
construction, emits Event variants from each, and forwards control
signals to the Controls.
Two seams meet here:
- Listeners (ListenerList + Listener::Base) — pure consumers
of the event stream, never writing back. The
Agentemits every loop-narration Event; extensions emit domain events through the ExtensionContext handed to Extension#bind. No public path leads from anAgentreference to emission (no +listeners+/+chat+ reader, no emit method), so holding an agent grants read access and nothing more. New sinks are added as listeners without touching Agent. - Controls (Control::StepLimit, Control::Cancellable,
Control::Interloper) — host-facing signal holders the
Agentreads at fixed boundaries: +tick!+/+check!+ on everybefore_tool_call,reset!at turn start,drain!once each tool batch is answered.
"What fires when" is one grep for @listeners.emit (loop narration)
plus the capability calls in ExtensionContext (domain events).
Step-exhaustion policy
When step_limit: trips during completion, #run_loop applies its
Control::StepLimit#on_exhausted policy:
:raise(default) — re-raise to the host. History survives and Control::StepLimit#reset! fires next turn, so "continue" works.:synthesize— emit an Event::FallbackNotice and run the Synthesizer prompt on a nested tools-freeAgent(listener stream via ListenerList#for_sub_agent,cancellableshared so a user cancel still works, a defensivestep_limitatmax: 1). Its answer becomes #last_assistant_content, so callers still get a reply.
Cancellation rescue
When cancellable: trips during Chat#ask, #run_loop emits an
Event::Cancelled and re-raises — no synthesizer fallback (cancel
means drop everything). Control::Cancellable#reset! at each turn
start lets the same instance take a fresh turn afterwards.
Threading
Thread-unsafe — and that is the whole guarantee, deliberately no
stronger. @chat, the message-identity map and the on_close list are
plain unguarded state, and #run_loop is not re-entrant, so a second
thread calling in mid-turn corrupts the conversation rather than raising.
Preventing concurrent access is the caller's job, by any means: one thread
per agent, a mutex around every call, an actor mailbox. Nothing here is
confined to a particular thread — an Agent may be driven from a
different thread on each turn, so never key anything about it off
Thread.current.
Several agents in one VM is then the easy case, one thread each:
agents.map { |a| Thread.new { a.run_loop(user_message: task_for(a)) } }.each(&:join)
Two Controls are the sanctioned doors *in* from another thread, which is why they carry locks and the agent does not: Control::Cancellable (stop the turn) and Control::Interloper (queue a mid-turn message, delivered at the next tool-batch boundary). Every other method needs the caller's own serialization.
Tools inherit nothing from this: an instance handed to two agents is
reachable from both, so each declares its own posture (see
Tool's == Sharing).
Defined Under Namespace
Modules: Control, Event, Extension, Listener, Synthesizer Classes: ChatTransport, Configurator, ContextWindowDetector, EmptyTurnError, ExtensionContext, History, ListenerList
Constant Summary collapse
- CANCELLED_RESULT =
What #run_loop answers a tool call with when its batch unwound — see
== Repairing an unwound tool batch. Public so a renderer can tell one from a real observation, as History::INTERRUPTED_RESULT is for the on-disk shape.Paired per cause: the first unanswered call is the one that raised or was about to, the rest never started, and telling a skipped call "you failed" invites a pointless retry. A tool failure has no constant — it reports
"Error: <class>: <message>", the shape Tool#run already returns for a validation failure. '[Cancelled by the user]'- CANCELLED_SKIPPED_RESULT =
'[Not executed — the user cancelled this turn]'- STEP_BUDGET_RESULT =
'[Not executed — step budget exhausted]'- FAILED_SKIPPED_RESULT =
'[Not executed — a previous tool call in this batch failed]'
Instance Attribute Summary collapse
-
#cancellable ⇒ Control::Cancellable?
readonly
The cancellation control, or
nil. -
#context_window_cap ⇒ Integer?
readonly
Resolved context-window cap — the ChatTransport#context_window if given, else the ContextWindowDetector probe;
nilwhen neither yields one. -
#extensions ⇒ Array<Extension>
readonly
Extensions bound to this agent.
-
#id ⇒ String
readonly
This agent's id — empty for the main agent, persona-rooted for sub-agents (+"researcher 0"+).
-
#interloper ⇒ Control::Interloper?
readonly
The mid-loop user-input control, or
nil. -
#step_limit ⇒ Control::StepLimit?
readonly
The step budget, or
nil. -
#streaming ⇒ Boolean
readonly
Whether this agent opted into chunk-level streaming (see #initialize's
streaming:). -
#sub_agent_tools ⇒ Array<Tool>
readonly
Tools added via Configurator#add_sub_agent_tool, in declaration order.
-
#system_prompt ⇒ String
readonly
System prompt actually sent — the base
system_prompt:plus every extension's Extension#system_prompt_snippets, re-assembled on #clear_conversation (so a dynamic section may differ from its construction value). -
#tools ⇒ Array<Tool>
readonly
Tool list in declaration order.
-
#transport ⇒ ChatTransport
readonly
The resolved transport this agent was built with — the model/provider/connection every
RubyLLM.chatfrom this agent uses (main chat, synthesizer, sub-agents).
Instance Method Summary collapse
-
#clear_conversation ⇒ void
Return the agent to a fresh-conversation state without tearing down its wiring — the host "/clear".
-
#close ⇒ void
Release agent-owned resources: fire every handler registered via Configurator#on_close and ExtensionContext#on_close in LIFO order, so a handler that depends on an earlier one tears down first.
-
#export_history ⇒ History
Snapshot the conversation as a History — the round-trip partner of #load_history!, and the supported way for a host to persist a conversation without reaching into the underlying chat.
- #initialize(transport:, system_prompt:, step_limit: nil, cancellable: nil, interloper: nil, id: '', streaming: false) {|Configurator| ... } ⇒ Agent constructor
-
#last_assistant_content ⇒ String?
Final assistant message content for the most recent #run_loop.
-
#load_history!(history) ⇒ Integer
Replace the conversation with a saved one, keeping every bit of wiring — tools, listeners, extensions, model — in place.
-
#model ⇒ String
Resolved model id from #transport.
-
#run_loop(user_message: nil, transport: nil) ⇒ nil
Run the agent loop for one turn.
-
#to_s ⇒ String
Short, single-line config dump suitable for a startup banner or a debug print.
-
#trifecta ⇒ Trifecta::Report
This agent's lethal-trifecta picture: which legs it holds, which its sub-agents hold, the verdict at every node.
Constructor Details
#initialize(transport:, system_prompt:, step_limit: nil, cancellable: nil, interloper: nil, id: '', streaming: false) {|Configurator| ... } ⇒ Agent
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/pikuri/agent.rb', line 175 def initialize(transport:, system_prompt:, step_limit: nil, cancellable: nil, interloper: nil, id: '', streaming: false, &block) @transport = transport.model ? transport : transport.with(model: RubyLLM.config.default_model) @cancellable = cancellable @closed = false # Kept separate from the assembled +@system_prompt+ so # {#assemble_system_prompt} can recompute (base + extensions' # snippets) from scratch at construction and on each # {#clear_conversation}, refreshing dynamic sections. @system_prompt_base = system_prompt @system_prompt = system_prompt @step_limit = step_limit @interloper = interloper @id = id @streaming = streaming @synth_answer = nil @on_close_handlers = [] # Stashed for {#run_configure}, the failure-prone build phase. @block = block # Register *before* the build phase so a mid-construction raise is # recoverable: extensions arm cleanup via +c.on_close+ (straight into # +@on_close_handlers+), and the rescue below fires whatever was armed # before the failure. On the happy path this is the at-exit backstop # if the host forgets {#close}; an explicit {#close} unregisters. Pikuri::Finalizers.register(self) begin run_configure rescue StandardError # Half-built agent: fire the armed handlers, drop out of the # registry, re-raise — no partial state leaks. close raise end end |
Instance Attribute Details
#cancellable ⇒ Control::Cancellable? (readonly)
Returns the cancellation control, or
nil. Extensions read it to propagate cancellation to their own
LLM calls (the agent tool shares it so one Ctrl+C stops the tree).
251 252 253 |
# File 'lib/pikuri/agent.rb', line 251 def cancellable @cancellable end |
#context_window_cap ⇒ Integer? (readonly)
Returns resolved context-window cap — the
Pikuri::Agent::ChatTransport#context_window if given, else the
ContextWindowDetector probe; nil when neither yields one.
Re-resolved on every model switch.
276 277 278 |
# File 'lib/pikuri/agent.rb', line 276 def context_window_cap @context_window_cap end |
#extensions ⇒ Array<Extension> (readonly)
Returns extensions bound to this agent. Each one's
configure ran during the Agent.new block, its bind at the end
of #initialize. Not inherited by sub-agents.
270 271 272 |
# File 'lib/pikuri/agent.rb', line 270 def extensions @extensions end |
#id ⇒ String (readonly)
Returns this agent's id — empty for the main agent,
persona-rooted for sub-agents (+"researcher 0"+). Distinct from the
persona name; propagated to listeners via
Pikuri::Agent::ListenerList#for_sub_agent so id-aware ones tag their output.
261 262 263 |
# File 'lib/pikuri/agent.rb', line 261 def id @id end |
#interloper ⇒ Control::Interloper? (readonly)
Returns the mid-loop user-input control, or
nil.
255 256 257 |
# File 'lib/pikuri/agent.rb', line 255 def interloper @interloper end |
#step_limit ⇒ Control::StepLimit? (readonly)
Returns the step budget, or nil.
246 247 248 |
# File 'lib/pikuri/agent.rb', line 246 def step_limit @step_limit end |
#streaming ⇒ Boolean (readonly)
Returns whether this agent opted into chunk-level streaming
(see #initialize's streaming:).
265 266 267 |
# File 'lib/pikuri/agent.rb', line 265 def streaming @streaming end |
#sub_agent_tools ⇒ Array<Tool> (readonly)
Returns tools added via
Pikuri::Agent::Configurator#add_sub_agent_tool, in declaration order. Never sent
to ruby_llm (invisible to the parent LLM); available only to
sub-agents whose persona tool_names match — the trifecta defense,
see Configurator.
231 232 233 |
# File 'lib/pikuri/agent.rb', line 231 def sub_agent_tools @sub_agent_tools end |
#system_prompt ⇒ String (readonly)
Returns system prompt actually sent — the base
system_prompt: plus every extension's
Pikuri::Agent::Extension#system_prompt_snippets, re-assembled on
#clear_conversation (so a dynamic section may differ from its
construction value). Not inherited by sub-agents.
243 244 245 |
# File 'lib/pikuri/agent.rb', line 243 def system_prompt @system_prompt end |
#tools ⇒ Array<Tool> (readonly)
Returns tool list in declaration order. These are the tools registered with ruby_llm — the parent LLM can call any. Cf. #sub_agent_tools.
224 225 226 |
# File 'lib/pikuri/agent.rb', line 224 def tools @tools end |
#transport ⇒ ChatTransport (readonly)
Returns the resolved transport this agent was built
with — the model/provider/connection every RubyLLM.chat from this
agent uses (main chat, synthesizer, sub-agents).
219 220 221 |
# File 'lib/pikuri/agent.rb', line 219 def transport @transport end |
Instance Method Details
#clear_conversation ⇒ void
This method returns an undefined value.
Return the agent to a fresh-conversation state without tearing down its wiring — the host "/clear". Unlike #close (which fires the teardown handlers), everything stays alive; nothing is re-probed or re-connected. Resets, in order:
- Chat history (+Chat#reset_messages!+, which also drops every injected
reference block), then re-applies a freshly re-assembled prompt —
#assemble_system_prompt re-pulls every extension's snippets, so
dynamic sections refresh (a resident memory persona, a re-read
MACHINE.md). Callbacks and tool registrations on the reused@chatare untouched. - Controls — step/cancellation reset, interloper queue drained (stale mid-loop input must not leak forward), cached synth answer dropped.
- Listeners — a single Pikuri::Agent::Event::Reset so they reset display state.
- Extension state — Pikuri::Agent::Extension#on_conversation_reset on each.
Model/transport are left as-is (a clear is not a model switch). Runs on the agent's own thread; not designed to race an in-flight #run_loop.
428 429 430 431 432 433 434 435 436 437 438 439 440 |
# File 'lib/pikuri/agent.rb', line 428 def clear_conversation @chat. @message_meta.clear @system_prompt = assemble_system_prompt @chat.with_instructions(@system_prompt) @synth_answer = nil @step_limit&.reset! @cancellable&.reset! @interloper&.drain! @listeners.emit(Event::Reset.new) @extensions.each { |ext| ext.on_conversation_reset(@extension_context) } nil end |
#close ⇒ void
This method returns an undefined value.
Release agent-owned resources: fire every handler registered via
Pikuri::Agent::Configurator#on_close and Pikuri::Agent::ExtensionContext#on_close in LIFO order,
so a handler that depends on an earlier one tears down first. Each runs
in its own rescue (an exception is logged, not propagated).
Idempotent.
540 541 542 543 544 545 546 547 548 549 550 551 552 |
# File 'lib/pikuri/agent.rb', line 540 def close return if @closed @closed = true # Drop out of the registry first: a deliberate close no longer needs # the at-exit fallback, and dropping the reference frees it for GC. 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.}") end end |
#export_history ⇒ History
Snapshot the conversation as a History — the round-trip partner of #load_history!, and the supported way for a host to persist a conversation without reaching into the underlying chat.
agent.export_history.save(dir) # a folder per conversation
File.write(f, agent.export_history.to_json) # or one self-contained file
The system prompt is excluded — #load_history! re-assembles it, so a resumed conversation gets today's, not the one that was current when it was saved.
Attachment bytes are read here, eagerly, so the snapshot still shows what the model saw after the file has changed on disk.
A thinking block withheld from the conversation because another model produced it is restored here from the message's metadata, so the record survives any number of load/save cycles under a switched model.
461 462 463 464 465 466 467 468 |
# File 'lib/pikuri/agent.rb', line 461 def export_history exported = @chat..reject { |m| m.role == :system }.map do |msg| = @message_meta[msg] || { id: History.mint_id, kind: 'user' } exported_msg = History::Message.from_ruby_llm(msg, id: [:id], kind: [:kind]) [:thinking] ? exported_msg.with(thinking: [:thinking]) : exported_msg end History.new(messages: exported) 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.
285 286 287 288 289 290 |
# File 'lib/pikuri/agent.rb', line 285 def last_assistant_content return @synth_answer if @synth_answer last = @chat..reverse.find { |m| m.role == :assistant } last&.content end |
#load_history!(history) ⇒ Integer
Replace the conversation with a saved one, keeping every bit of wiring — tools, listeners, extensions, model — in place.
agent.load_history!(History.load(dir))
Runs #clear_conversation first, so controls reset and extensions see Pikuri::Agent::Extension#on_conversation_reset exactly as they would on a host "/clear"; the system prompt is re-assembled from current state. Then emits Pikuri::Agent::Event::HistoryLoaded — Pikuri::Agent::Event::Reset alone would tell listeners the conversation is empty, which it no longer is.
Safe to call repeatedly on a long-lived agent: switching conversations in a shell is exactly this, once per switch, and nothing survives across one. Like the rest of Pikuri::Agent, it must not race a running #run_loop.
What is restored, and what is not
Messages, tool calls and their results, thinking, attachments, per-message usage. Not state living outside the conversation: a workspace's read-before-edit marks, promoted skills, a task list. A resumed conversation whose transcript says a file was read will still be asked to read it again before editing — which is right, since the file may have changed while the conversation sat on disk.
Two repairs happen on the way in, both because a stored conversation can describe something a provider will not accept:
- A tool call whose result never arrived gets a synthesized one (Pikuri::Agent::History#repair_interrupted_tool_calls).
- A turn produced by a different model loses its whole thinking block
— a foreign one lands in a slot the receiving model reads as its own
scratchpad. Withheld from the conversation only; #export_history
still writes it. #apply_transport! repeats this for a switch that
happens later. See
DECISIONS.mdD_thinking_block_replay.
510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 |
# File 'lib/pikuri/agent.rb', line 510 def load_history!(history) unless history.is_a?(History) raise ArgumentError, "expected a Pikuri::Agent::History, got #{history.class} — " \ 'use History.parse / History.load to build one from stored bytes' end clear_conversation current_model = @chat.model&.id repaired = history.repair_interrupted_tool_calls repaired..each do |m| keep = keep_thinking?(m.thinking, m.model_id, current_model) (@chat.(m.to_ruby_llm(keep_thinking: keep)), kind: m.kind, id: m.id, thinking: (m.thinking unless keep)) end last = repaired..reverse.find { |m| m.role == 'assistant' && m.tokens } @listeners.emit(Event::HistoryLoaded.new( messages: repaired..size, tokens: last && Event::Tokens.new(**last.tokens.to_h, model_id: last.model_id) )) repaired..size end |
#model ⇒ String
Returns resolved model id from #transport.
234 235 236 |
# File 'lib/pikuri/agent.rb', line 234 def model @transport.model end |
#run_loop(user_message: nil, transport: nil) ⇒ nil
Run the agent loop for one turn. With a user_message, emits an
Pikuri::Agent::Event::UserTurn (+mid_loop: false+) and appends it; with none, runs a
bare turn against whatever the conversation + drained injections
already hold — the case a host uses after loading a skill on its own
initiative, so the agent reacts to it. Resets the step/cancellation
controls, drains any pending Pikuri::Agent::Control::Interloper injections, then
+complete+s the chat. Output is the listeners' job; subsequent calls
build on the same history.
A blank user_message against a conversation holding nothing but the
system prompt is provider-split: providers that can't complete it raise
EmptyTurnError, the rest run the turn against that history. A blank turn
mid-conversation always proceeds — real turns remain to answer, and a
drained injection is itself one.
Step-limit and cancellation trips are handled per the class header's "Step-exhaustion policy" / "Cancellation rescue".
Switching models mid-conversation
A transport: differing from the current one switches the chat to it
(via #apply_transport!), re-resolves the cap, and emits
Pikuri::Agent::Event::ModelSwitched + a fresh Pikuri::Agent::Event::ContextCap. The switch is
confined to the top of this method because the chat is
single-thread-confined — a background thread mutating the connection
mid-completion would tear an in-flight stream. The conversation is
not re-baselined: same conversation, new model, so message count and
running context size carry over. nil keeps the current model.
Repairing an unwound tool batch
ruby_llm appends the assistant tool_calls message before running any
of them, so anything unwinding out of the batch — a tool raising, a
Pikuri::Agent::Control::Cancellable::Cancelled, a tripped budget, an untrapped
Interrupt — leaves calls no :tool message answers. OpenAI and
Anthropic 400 on that; a local llama.cpp feeds it to the chat template
and the model improvises. So an ensure answers each dangling call
(#repair_dangling_tool_calls!) and the exception then propagates: a
host that keeps the conversation gets one it can still send.
ensure rather than a clause per rescue, because it also catches what
nobody enumerated (+Interrupt+ is no StandardError) and it runs after
the :synthesize rescue body, leaving Pikuri::Agent::Synthesizer.build_prompt the
unrepaired history its drop-calls-with-no-result pass expects.
Unlike Pikuri::Agent::History#repair_interrupted_tool_calls — the load path, where a killed process leaves no clue and Pikuri::Agent::History::INTERRUPTED_RESULT has to be generic — the cause is in hand here and picks the wording.
353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 |
# File 'lib/pikuri/agent.rb', line 353 def run_loop(user_message: nil, transport: nil) apply_transport!(transport) if transport @synth_answer = nil @step_limit&.reset! @cancellable&.reset! # Deliver host-scheduled injections (e.g. a UI-loaded skill) before the # first complete, so a freshly-scheduled block lands this turn rather # than only after the next tool batch — ordered ahead of the user # message appended below. drain_interloper if @interloper blank = .nil? || .to_s.strip.empty? # These providers would send `messages: []` here (see EmptyTurnError). # The drain above is what usually saves this case: an injected block is a # :user message, so a skill-only bare load has something to complete # against and falls through. if blank && @chat..all? { |m| m.role == :system } && SEPARATE_SYSTEM_FIELD_PROVIDERS.include?(@chat.model.provider.to_s) raise EmptyTurnError, @chat.model.provider end unless blank # 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. (+ask+ bundles append+complete # atomically, leaving no seam to inject between; the halves run # explicitly instead.) (@chat.(role: :user, content: )) @listeners.emit(Event::UserTurn.new(content: , mid_loop: false)) () end if @streaming @chat.complete(&streaming_block) else @chat.complete end nil rescue Control::Cancellable::Cancelled @listeners.emit(Event::Cancelled.new) raise rescue Control::StepLimit::Exceeded => e raise unless @step_limit&.on_exhausted == :synthesize # Ruby restores +$!+ once a rescue clause completes normally, so the # +ensure+ below would see no cause for this one unwind. Assigned before # the synth runs, so a synth that raises doesn't displace the real reason. exhausted = e @synth_answer = Synthesizer.run_synthesizer(@extension_context, @chat., ) nil ensure repair_dangling_tool_calls!(exhausted || $!) end |
#to_s ⇒ String
Short, single-line config dump suitable for a startup banner or a debug print.
562 563 564 |
# File 'lib/pikuri/agent.rb', line 562 def to_s "Agent(id=#{@id}, model=#{model}, tools=#{@tools.size}, listeners=#{@listeners})" end |
#trifecta ⇒ Trifecta::Report
This agent's lethal-trifecta picture: which legs it holds, which its sub-agents hold, the verdict at every node.
puts agent.trifecta.render # the map a bin script prints
agent.trifecta.tree_verdict # => :soft
Memoized and pure, so a UI may call it as often as it likes. Nothing
reports this for you: an Agent computes the picture and stops, because a
library writing to a log its embedder never asked for is noise. Surfacing
it is the host's job — the bundled bin/pikuri-* scripts print
Trifecta::Report#render at boot.
Valid only after construction (it reads the wired tool list), and it reflects the tools present then.
582 583 584 |
# File 'lib/pikuri/agent.rb', line 582 def trifecta @trifecta ||= Trifecta.walk(Trifecta.build(self)) end |