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/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:

  1. Listeners (ListenerList + Listener::Base) — pure consumers of the event stream, never writing back. The Agent emits every loop-narration Event; extensions emit domain events through the ExtensionContext handed to Extension#bind. No public path leads from an Agent reference 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.
  2. Controls (Control::StepLimit, Control::Cancellable, Control::Interloper) — host-facing signal holders the Agent reads at fixed boundaries: +tick!+/+check!+ on every before_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:

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 =

See Also:

'[Not executed — the user cancelled this turn]'
STEP_BUDGET_RESULT =

See Also:

'[Not executed — step budget exhausted]'
FAILED_SKIPPED_RESULT =

See Also:

'[Not executed — a previous tool call in this batch failed]'

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

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

Parameters:

  • transport (ChatTransport)

    model-resolution bundle (+model+ / provider / assume_model_exists and, off the global server, api_base / api_key). A nil transport.model is filled from RubyLLM.config.default_model.

  • system_prompt (String)

    base system message; the chat sees this plus every extension's Pikuri::Agent::Extension#system_prompt_snippets, assembled by #assemble_system_prompt at construction and on each #clear_conversation.

  • step_limit (Control::StepLimit, nil) (defaults to: nil)

    step budget, or nil for no budget (unbounded looping). Its Pikuri::Agent::Control::StepLimit#on_exhausted policy drives #run_loop on trip (see the class header).

  • cancellable (Control::Cancellable, nil) (defaults to: nil)

    cancellation control, or nil for none (no way to stop a running turn but killing the process).

  • interloper (Control::Interloper, nil) (defaults to: nil)

    mid-loop user-input queue drained once each tool batch is answered (each item becomes an Pikuri::Agent::Event::UserTurn with mid_loop: true), or nil for none.

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

    unique id; empty for the main agent, persona-rooted for sub-agents (+"researcher 0"+, ...). Distinct from a persona's name (the value the LLM picks in the agent tool's name: arg).

  • streaming (Boolean) (defaults to: false)

    opt into chunk-level streaming — #run_loop passes a per-chunk block to Chat#complete and normalizes chunks into Pikuri::Agent::Event::ThinkingDelta/Pikuri::Agent::Event::AssistantDelta. Default false (only the after_message bookends fire).

Yields:

  • (Configurator)

    the sole path for adding tools, listeners, extensions, and on_close handlers — there are no parallel ctor kwargs. Optional; without it the agent has none of those.



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

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

Returns:

  • (Control::Cancellable, nil)

    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_capInteger? (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.

Returns:



276
277
278
# File 'lib/pikuri/agent.rb', line 276

def context_window_cap
  @context_window_cap
end

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

Returns:

  • (Array<Extension>)

    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

#idString (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.

Returns:

  • (String)

    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

#interloperControl::Interloper? (readonly)

Returns the mid-loop user-input control, or nil.

Returns:



255
256
257
# File 'lib/pikuri/agent.rb', line 255

def interloper
  @interloper
end

#step_limitControl::StepLimit? (readonly)

Returns the step budget, or nil.

Returns:



246
247
248
# File 'lib/pikuri/agent.rb', line 246

def step_limit
  @step_limit
end

#streamingBoolean (readonly)

Returns whether this agent opted into chunk-level streaming (see #initialize's streaming:).

Returns:

  • (Boolean)

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

Returns:



231
232
233
# File 'lib/pikuri/agent.rb', line 231

def sub_agent_tools
  @sub_agent_tools
end

#system_promptString (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.

Returns:



243
244
245
# File 'lib/pikuri/agent.rb', line 243

def system_prompt
  @system_prompt
end

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

Returns:

  • (Array<Tool>)

    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

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

Returns:

  • (ChatTransport)

    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_conversationvoid

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:

  1. 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 @chat are untouched.
  2. Controls — step/cancellation reset, interloper queue drained (stale mid-loop input must not leak forward), cached synth answer dropped.
  3. Listeners — a single Pikuri::Agent::Event::Reset so they reset display state.
  4. 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.reset_messages!
  @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

#closevoid

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.message}")
  end
end

#export_historyHistory

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.

Returns:



461
462
463
464
465
466
467
468
# File 'lib/pikuri/agent.rb', line 461

def export_history
  exported = @chat.messages.reject { |m| m.role == :system }.map do |msg|
    meta = @message_meta[msg] || { id: History.mint_id, kind: 'user' }
    exported_msg = History::Message.from_ruby_llm(msg, id: meta[:id], kind: meta[:kind])
    meta[:thinking] ? exported_msg.with(thinking: meta[:thinking]) : exported_msg
  end
  History.new(messages: exported)
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)


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.messages.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::HistoryLoadedPikuri::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.md D_thinking_block_replay.

Parameters:

Returns:

  • (Integer)

    how many messages were restored.

Raises:

  • (ArgumentError)

    if given anything but a History; the parsing doors are where untrusted input belongs.



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.messages.each do |m|
    keep = keep_thinking?(m.thinking, m.model_id, current_model)
    track_message(@chat.add_message(m.to_ruby_llm(keep_thinking: keep)),
                  kind: m.kind, id: m.id, thinking: (m.thinking unless keep))
  end

  last = repaired.messages.reverse.find { |m| m.role == 'assistant' && m.tokens }
  @listeners.emit(Event::HistoryLoaded.new(
                    messages: repaired.messages.size,
                    tokens: last && Event::Tokens.new(**last.tokens.to_h, model_id: last.model_id)
                  ))
  repaired.messages.size
end

#modelString

Returns resolved model id from #transport.

Returns:



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.

Parameters:

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

    the turn's request; +nil+/blank runs a bare turn (see above)

  • transport (ChatTransport, nil) (defaults to: nil)

    when non-+nil+ and structurally different, switch to it before the turn; nil keeps the model

Returns:

  • (nil)

    after a turn runs

Raises:



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 = user_message.nil? || user_message.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.messages.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.)
    track_message(@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)
  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.messages, user_message)
  nil
ensure
  repair_dangling_tool_calls!(exhausted || $!)
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)


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

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

#trifectaTrifecta::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.

Returns:



582
583
584
# File 'lib/pikuri/agent.rb', line 582

def trifecta
  @trifecta ||= Trifecta.walk(Trifecta.build(self))
end