Module: Pikuri::Agent::Synthesizer

Defined in:
lib/pikuri/agent/synthesizer.rb

Overview

Step-exhaustion rescue. When an Agent‘s Control::StepLimit trips, Agent#run_loop catches the Exceeded exception and hands off to Synthesizer.run so the run still produces something useful — a tools-free assistant turn that answers the user’s question from whatever evidence the failed agent collected before running out of budget.

Why this exists

Without a rescue, a step-exhausted run just raises a stack trace past bin/pikuri-chat and the user gets nothing despite the agent having gathered useful information in the first N-1 steps. The observed failure mode is the “wait, but what about X?” death-loop: the agent collects sound evidence in the first few rounds, then spends the rest of the budget second-guessing. By the time the cap trips, the answer is largely in the messages — it just needs a tools-free pass to synthesize.

Seam discipline

Synthesizer.run does not reference RubyLLM::*. Agent constructs the synth chat itself (the one RubyLLM.chat call lives in lib/agent.rb, same as the parent chat) and passes it in. Synthesizer only calls instance methods on whatever chat it receives — #with_instructions, #ask, #messages — and uses wire_chat for the event-stream wiring so the synth chat emits events with the same shape as the main chat.

Constant Summary collapse

SYSTEM_PROMPT =

The synthesizer’s system prompt. Strict and short: use the evidence, don’t apologize, admit gaps when present.

<<~PROMPT
  You are given evidence another agent collected before running out of steps. Answer the user's question using only this evidence. You have no tools. If the evidence is insufficient, state plainly what's missing and what partial answer you can give. Do not apologize or comment on the previous agent.
PROMPT

Class Method Summary collapse

Class Method Details

.build_prompt(parent_messages:, user_message:) ⇒ String

Render the user’s question plus an “Evidence gathered” section built from parent_messages as a single prompt string. Pure function — no I/O, safe to test directly with fixture messages.

Parameters:

  • parent_messages (Array<RubyLLM::Message>)
  • user_message (String)

Returns:

  • (String)


102
103
104
105
# File 'lib/pikuri/agent/synthesizer.rb', line 102

def self.build_prompt(parent_messages:, user_message:)
  transcript = format_evidence(parent_messages)
  "Question: #{user_message}\n\nEvidence gathered:\n#{transcript}"
end

.run(chat:, parent_messages:, user_message:, listeners:, step_limit: nil, cancellable: nil, streaming: false) ⇒ String?

Configure chat for synthesis, run one turn against it, and return the final assistant content. The chat is wired for the event stream via Pikuri::Agent.wire_chat so the synth’s reasoning and answer flow through the same listener surface the parent agent uses — terminal renders them inline (padded under sub-agent), an in-memory recorder picks them up, a TokenLog tags them with the synth name.

Parameters:

  • chat (RubyLLM::Chat)

    a fresh chat with no tools. The caller is responsible for constructing it with the same model/provider configuration the parent used.

  • parent_messages (Array<RubyLLM::Message>)

    the parent chat’s full message history at the moment of step exhaustion. Used to build the evidence transcript.

  • user_message (String)

    the user’s original question from the parent turn that exhausted.

  • listeners (Agent::ListenerList)

    listeners to wire the synth chat into. Typically the parent agent’s list run through ListenerList#for_sub_agent with the synth’s name: so any TokenLog tags its lines with the synth bracket and any Terminal pads its output.

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

    defensive step budget. The synth has no tools so it should never trip before_tool_call, but a buggy provider that somehow returned a tool call would loop without one. Pass nil to skip.

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

    cancellation control. Typically the parent’s instance, shared by reference so a user cancel during synthesis still works. Pass nil to skip.

  • streaming (Boolean) (defaults to: false)

    mirror the parent agent’s streaming flag. When true, Pikuri::Agent.streaming_block is passed to chat.ask so the synth’s reasoning and answer flow through the listener stream as deltas in addition to the final Event::Thinking / Event::Assistant bookends.

Returns:

  • (String, nil)

    the synth’s final assistant content, or nil if the synth somehow produced no assistant message



81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/pikuri/agent/synthesizer.rb', line 81

def self.run(chat:, parent_messages:, user_message:, listeners:,
             step_limit: nil, cancellable: nil, streaming: false)
  chat.with_instructions(SYSTEM_PROMPT)
  Agent.wire_chat(chat, listeners: listeners, step_limit: step_limit, cancellable: cancellable)
  prompt = build_prompt(parent_messages: parent_messages, user_message: user_message)
  if streaming
    chat.ask(prompt, &Agent.streaming_block(listeners: listeners, cancellable: cancellable))
  else
    chat.ask(prompt)
  end
  chat.messages.reverse.find { |m| m.role == :assistant }&.content
end