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/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/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: pikuri owns the *extension surface* (the event-stream listeners that consume normalized chat events, plus the controls that signal back into the loop) while ruby_llm owns the loop itself. The Thought / Tool-call / Observation iteration lives in Chat#complete; pikuri’s job is wiring ruby_llm’s three callbacks at construction time, emitting Event variants from each, and forwarding control signals (step-budget tick, cancellation check, mid-loop input drain) to the appropriate Control.
Roles in this file
Two seams are visible:
-
Listeners (ListenerList + Listener::Base subclasses) — pure consumers of the event stream. The
Agentis the sole emitter; listeners never write back. New rendering or capture targets (a web sink, a structured log) are added here without touching Agent. -
Controls (Control::StepLimit, Control::Cancellable, Control::Interloper) — host-facing signal holders. The
Agentreads from them at well-defined boundaries: Control::StepLimit#tick! on everybefore_tool_call(and Control::StepLimit#reset! at turn start), Control::Cancellable#check! on everybefore_tool_call(and Control::Cancellable#reset! at turn start), Control::Interloper#drain! on everyafter_tool_result.
The two roles are named separately so “what fires when” is a single grep for @listeners.emit in this file.
Step-exhaustion rescue
If the step_limit: Control::StepLimit trips during Chat#ask, #run_loop catches the Exceeded exception, emits an Event::FallbackNotice to the listener stream, and hands off to Synthesizer.run on a fresh RubyLLM::Chat. The synth reuses the parent’s listener stream via ListenerList#for_sub_agent (Terminal padded, TokenLog zeroed, recorder shared by reference) with a name: derived from the parent’s. The synth shares the parent’s cancellable so a user cancel during synthesis still works, and gets a fresh step_limit at max: 1 (defensive — the synth has no tools and shouldn’t trip it). The synth’s answer becomes the value reported by #last_assistant_content, so callers (notably Tool::SubAgent) still get a usable reply.
Cancellation rescue
If the cancellable: Control::Cancellable trips during Chat#ask, #run_loop catches the Cancelled exception, emits an Event::Cancelled to the listener stream, and re-raises. No synthesizer fallback runs: cancellation means the user asked the agent to drop everything, so salvaging a partial answer would be the wrong move. The caller (typically a REPL) rescues the re-raised exception and returns control to the user; because #run_loop calls Control::Cancellable#reset! at the start of every turn, the same agent instance can take a fresh turn immediately afterwards.
Defined Under Namespace
Modules: Control, Event, Extension, Listener, Synthesizer Classes: ChatTransport, Configurator, ContextWindowDetector, ListenerList
Instance Attribute Summary collapse
-
#cancellable ⇒ Control::Cancellable?
readonly
The cancellation control this agent was constructed with, or
nilwhen none. -
#chat ⇒ RubyLLM::Chat
readonly
Underlying chat; the extension seam.
-
#context_window_cap ⇒ Integer?
readonly
Context-window cap resolved by ContextWindowDetector at construction time.
-
#extensions ⇒ Array<Extension>
readonly
Extension instances bound to this agent — added via Configurator#add_extension (new — runs
configurenow and binds later) or Configurator#inherit_extensions (sub-agent inheritance — skipsconfigure, just binds), both inside theAgent.newblock. -
#interloper ⇒ Control::Interloper?
readonly
The mid-loop user-input control this agent was constructed with, or
nilwhen none. -
#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”).
-
#step_limit ⇒ Control::StepLimit?
readonly
The step-budget control this agent was constructed with, or
nilwhen none. -
#streaming ⇒ Boolean
readonly
truewhen this agent opted into chunk-level streaming (see thestreaming:kwarg on #initialize);falseotherwise. -
#system_prompt ⇒ String
readonly
System prompt actually sent to the chat — equal to the constructor’s
system_prompt:argument plus any snippets appended by extensions during Configurator#append_system_prompt (Skills’ <available_skills>, MCP’s <available_mcps>, …). -
#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).
Class Method Summary collapse
-
.streaming_block(listeners:, cancellable: nil) ⇒ Proc
Build the per-chunk streaming block passed to Chat#ask.
-
.think(transport:, prompt:, cancellable: nil) ⇒ String
One-shot inference.
-
.wire_chat(chat, listeners:, step_limit: nil, cancellable: nil, interloper: nil) ⇒ void
Wire one
RubyLLM::Chatfor pikuri’s event stream and controls.
Instance Method Summary collapse
-
#close ⇒ void
Release agent-owned resources.
- #initialize(transport:, system_prompt:, step_limit: nil, cancellable: nil, interloper: nil, context_window: nil, llama_probe_url: nil, name: '', streaming: false) {|Configurator| ... } ⇒ Agent constructor
-
#internal_add_tool(ruby_llm_tool) ⇒ void
Register a raw
RubyLLM::Toolsubclass on this agent’s underlying chat, bypassing the Tool strict-validation seam. -
#last_assistant_content ⇒ String?
Final assistant message content for the most recent #run_loop.
-
#model ⇒ String
Resolved model id from #transport.
-
#on_close { ... } ⇒ void
Register a handler called by #close.
-
#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:, step_limit: nil, cancellable: nil, interloper: nil, context_window: nil, llama_probe_url: nil, name: '', streaming: false) {|Configurator| ... } ⇒ Agent
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 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 |
# File 'lib/pikuri/agent.rb', line 367 def initialize(transport:, system_prompt:, step_limit: nil, cancellable: nil, interloper: nil, context_window: nil, llama_probe_url: nil, name: '', streaming: false, &block) @transport = transport.model ? transport : transport.with(model: RubyLLM.config.default_model) @cancellable = cancellable @closed = false @system_prompt = system_prompt @step_limit = step_limit @interloper = interloper @name = name @streaming = streaming @synth_answer = nil @on_close_handlers = [] # Single Configurator funnel for everything the block adds — # tools, listeners, system-prompt snippets, extensions # (both newly-configured via #add_extension and inherited # via #inherit_extensions for sub-agents), on_close handlers, # and the sub-agent request. See IDEAS.md §"Extension protocol # design". configurator = Configurator.new( transport: @transport, system_prompt_base: system_prompt, name: @name, streaming: @streaming, step_limit: @step_limit, cancellable: @cancellable, interloper: @interloper ) block&.call(configurator) @tools = configurator.tools.dup @listeners = ListenerList.new(configurator.listeners) configurator.system_prompt_additions.each do |snippet| @system_prompt = "#{@system_prompt}\n\n#{snippet}" end @on_close_handlers.concat(configurator.on_close_handlers) @extensions = configurator.extensions.dup @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 self.class.wire_chat( @chat, listeners: @listeners, step_limit: @step_limit, cancellable: @cancellable, interloper: @interloper ) # One-shot context-window cap: lets every listener that # cares (notably TokenLog) pick the value off the stream # before any Tokens event arrives. @listeners.emit(Event::ContextCap.new(cap: @context_window_cap)) # Sub-agent tool: constructed *after* @tools is final and # @context_window_cap is set, so its snapshot of the parent's # tool list doesn't include itself (recursion guard) and the # cap can be threaded through to spawned sub-agents. The new # +Tool::SubAgent+ instance is appended to both +@tools+ and # +@chat+, so sub-agents inheriting via the snapshot still # get the surrounding tool set but never the +sub_agent+ tool # itself. See {Configurator#allow_sub_agent}. if configurator.sub_agent_request if @tools.any?(Tool::SubAgent) raise 'Tool::SubAgent must not be added via c.add_tool when c.allow_sub_agent ' \ 'is used; Agent auto-registers it from the Configurator request.' end sub_tool = Tool::SubAgent.new(self, max_steps: configurator.sub_agent_request.max_steps) @tools << sub_tool @chat.with_tool(sub_tool.to_ruby_llm_tool) end # Bind sweep — each extension gets its chance to install # per-agent state (dynamic tools via #internal_add_tool, # per-agent close hooks via #on_close, etc.) now that the # chat is fully wired. See IDEAS.md §"Extension protocol # design" for what #configure vs #bind are each for. @extensions.each { |ext| ext.bind(self) } # Fallback cleanup: if the host forgets to call #close, the # at_exit hook fires it on process exit. Idempotent, so an # explicit close earlier makes this a no-op. The closure # captures self, which keeps the agent reachable until # process exit — fine for the handful of agents a typical # host creates; if pikuri grows a long-running host that # constructs many short-lived agents, switch to a single # process-global registry that close-then-removes. at_exit { close } end |
Instance Attribute Details
#cancellable ⇒ Control::Cancellable? (readonly)
Returns the cancellation control this agent was constructed with, or nil when none. Read by Tool::SubAgent so spawned sub-agents share the same instance.
521 522 523 |
# File 'lib/pikuri/agent.rb', line 521 def cancellable @cancellable end |
#chat ⇒ RubyLLM::Chat (readonly)
Returns underlying chat; the extension seam.
471 472 473 |
# File 'lib/pikuri/agent.rb', line 471 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.
561 562 563 |
# File 'lib/pikuri/agent.rb', line 561 def context_window_cap @context_window_cap end |
#extensions ⇒ Array<Extension> (readonly)
Returns extension instances bound to this agent — added via Pikuri::Agent::Configurator#add_extension (new — runs configure now and binds later) or Pikuri::Agent::Configurator#inherit_extensions (sub-agent inheritance — skips configure, just binds), both inside the Agent.new block. Read by Tool::SubAgent so spawned sub-agents inherit the parent’s extension list and re-bind them via the bind sweep.
553 554 555 |
# File 'lib/pikuri/agent.rb', line 553 def extensions @extensions end |
#interloper ⇒ Control::Interloper? (readonly)
Returns the mid-loop user-input control this agent was constructed with, or nil when none. Not propagated to sub-agents — see Pikuri::Agent::Control::Interloper#for_sub_agent.
527 528 529 |
# File 'lib/pikuri/agent.rb', line 527 def interloper @interloper end |
#listeners ⇒ ListenerList (readonly)
Returns the listener list attached to this agent’s chat.
509 510 511 |
# File 'lib/pikuri/agent.rb', line 509 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.
537 538 539 |
# File 'lib/pikuri/agent.rb', line 537 def name @name end |
#step_limit ⇒ Control::StepLimit? (readonly)
Returns the step-budget control this agent was constructed with, or nil when none. Read by Tool::SubAgent so spawned sub-agents derive their own.
515 516 517 |
# File 'lib/pikuri/agent.rb', line 515 def step_limit @step_limit end |
#streaming ⇒ Boolean (readonly)
Returns true when this agent opted into chunk-level streaming (see the streaming: kwarg on #initialize); false otherwise. Read by Tool::SubAgent so spawned sub-agents inherit the same mode.
544 545 546 |
# File 'lib/pikuri/agent.rb', line 544 def streaming @streaming end |
#system_prompt ⇒ String (readonly)
Returns system prompt actually sent to the chat —equal to the constructor’s system_prompt: argument plus any snippets appended by extensions during Pikuri::Agent::Configurator#append_system_prompt (Skills’ <available_skills>, MCP’s <available_mcps>, …). Tool::SubAgent forwards this already-augmented value to spawned sub-agents so they see the same advertisements without re-running extension configure.
505 506 507 |
# File 'lib/pikuri/agent.rb', line 505 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).
488 489 490 |
# File 'lib/pikuri/agent.rb', line 488 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.
480 481 482 |
# File 'lib/pikuri/agent.rb', line 480 def transport @transport end |
Class Method Details
.streaming_block(listeners:, cancellable: nil) ⇒ Proc
Build the per-chunk streaming block passed to Chat#ask. Each invocation of the returned proc converts one RubyLLM::Chunk into zero, one, or two delta events (Pikuri::Agent::Event::ThinkingDelta / Pikuri::Agent::Event::AssistantDelta) on listeners. Tool-call chunks are intentionally ignored —partial JSON has no useful rendering; the assembled tool_calls surface through Pikuri::Agent::Event::ToolCall once the message completes.
Lives parallel to wire_chat (instead of being folded into it) because Chat#ask takes the streaming block as an argument rather than a registered callback, so both #run_loop and Pikuri::Agent::Synthesizer.run pass it inline at the call site with &Agent.streaming_block(listeners: …, cancellable: …).
Cancellation polling
When cancellable is non-nil, Pikuri::Agent::Control::Cancellable#check! fires before each chunk’s emit. The before_tool_call wiring in wire_chat only fires when the model requests a tool, which leaves a no-tool turn (e.g. a plain greeting) with zero cancellation points — Ctrl+C trips the flag but nothing reads it. Polling on every streamed chunk closes that gap: an in-flight Cancellation+check! raises on the next chunk delivered after the flag flips, the exception propagates out through ruby_llm’s streaming path (+Chat#ask+ doesn’t rescue), and #run_loop catches it, emits Pikuri::Agent::Event::Cancelled, and re-raises. The pre-emit ordering is deliberate: a chunk that arrives after a cancel request shouldn’t render — the user has said stop.
151 152 153 154 155 156 |
# File 'lib/pikuri/agent.rb', line 151 def self.streaming_block(listeners:, cancellable: nil) ->(chunk) { cancellable&.check! emit_chunk(chunk, listeners) } end |
.think(transport:, prompt:, cancellable: nil) ⇒ String
One-shot inference. Builds a fresh RubyLLM::Chat with no tools, no MCP, no listeners, no step budget, asks prompt as the single user turn, and returns the assistant’s reply as a plain String. Lives parallel to #initialize / #run_loop because the use case (e.g. summarizing an MCP server’s tool set into a short description block before any agent turn runs) is genuinely one-shot — there is no loop, no tool iteration, no listener stream.
prompt is sent as the user message. For a one-shot call there is no behavioral difference between the system slot and the user slot, so we use one parameter; pack any “instructions + data” framing into prompt directly.
Cancellation
Pikuri::Agent::Control::Cancellable#check! fires once before the call and once after, so a flag flipped right around the request raises Pikuri::Agent::Control::Cancellable::Cancelled promptly. The in-flight HTTP call itself is not interrupted — same “gentle cancel” semantic the main loop offers (see Pikuri::Agent::Control::Cancellable‘s class header). For 30s synthesis passes at boot this is still a useful escape hatch: the next check raises and the call returns.
Failure
Errors from the provider (HTTP failure, malformed response, RubyLLM raising) propagate to the caller verbatim — there is no recovery layer here. Callers that want “fail soft on synthesis errors” (e.g. Mcp::Servers) rescue at their level and fall back to a default; this method stays loud.
281 282 283 284 285 286 287 288 289 290 291 |
# File 'lib/pikuri/agent.rb', line 281 def self.think(transport:, prompt:, cancellable: nil) raise ArgumentError, "prompt must not be blank, got #{prompt.inspect}" \ if prompt.nil? || prompt.to_s.strip.empty? transport = transport.with(model: RubyLLM.config.default_model) unless transport.model cancellable&.check! chat = RubyLLM.chat(**transport.to_h) chat.ask(prompt) last = chat..reverse.find { |m| m.role == :assistant } last&.content.to_s end |
.wire_chat(chat, listeners:, step_limit: nil, cancellable: nil, interloper: nil) ⇒ void
This method returns an undefined value.
Wire one RubyLLM::Chat for pikuri’s event stream and controls. Used by both #initialize (on the main chat) and Pikuri::Agent::Synthesizer.run (on the synth chat) so the two share one source of truth for “which callback emits which event.”
Handles the three message-level registered callbacks (after_message, before_tool_call, after_tool_result); the per-chunk streaming callback is separate because ruby_llm takes it as a block to Chat#ask rather than a registered hook — see streaming_block.
98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/pikuri/agent.rb', line 98 def self.wire_chat(chat, listeners:, step_limit: nil, cancellable: nil, interloper: nil) chat. do |msg| (msg, listeners) end chat.before_tool_call do |tc| listeners.emit(Event::ToolCall.new(name: tc.name, arguments: tc.arguments)) step_limit&.tick! cancellable&.check! end chat.after_tool_result do |result| listeners.emit(Event::ToolResult.new(content: result)) drain_interloper(interloper, chat, listeners) if interloper end end |
Instance Method Details
#close ⇒ void
This method returns an undefined value.
Release agent-owned resources. Fires every handler registered via Pikuri::Agent::Configurator#on_close (during the Agent.new block) and #on_close (during Pikuri::Agent::Extension#bind or any post-construction call), in LIFO order — matches Ruby ensure-block semantics so handlers registered later (which may depend on handlers registered earlier) tear down first. Each handler runs inside its own rescue; an exception is logged via Pikuri.logger_for but doesn’t abort the rest. Idempotent —subsequent calls are no-ops.
669 670 671 672 673 674 675 676 677 678 |
# File 'lib/pikuri/agent.rb', line 669 def close return if @closed @closed = true @on_close_handlers.reverse_each do |handler| handler.call rescue StandardError => e LOGGER.warn("on_close handler raised #{e.class}: #{e.}") end end |
#internal_add_tool(ruby_llm_tool) ⇒ void
This method returns an undefined value.
Register a raw RubyLLM::Tool subclass on this agent’s underlying chat, bypassing the Tool strict-validation seam. Sole intended caller: Mcp::Servers::Connect, which uses this to lazy-add MCP-exposed tools after the LLM invokes mcp_connect in a turn.
The internal_ prefix is the warning: native pikuri tools should go through Tool so they get Tool::Parameters validation and the LLM-actionable “Error: …” contract. MCP tools deliberately don’t — see IDEAS.md §“v1 implementation shape” / “MCP tools bypass Pikuri::Tool entirely.”
The added tool does NOT enter @tools, only @chat‘s tool list. Tool::SubAgent therefore cannot snapshot it (which is the whole point — activation is strictly per-agent, see IDEAS.md §“Per-agent activation, no propagation”).
715 716 717 |
# File 'lib/pikuri/agent.rb', line 715 def internal_add_tool(ruby_llm_tool) @chat.with_tool(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.
570 571 572 573 574 575 |
# File 'lib/pikuri/agent.rb', line 570 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.
493 494 495 |
# File 'lib/pikuri/agent.rb', line 493 def model @transport.model end |
#on_close { ... } ⇒ void
This method returns an undefined value.
Register a handler called by #close. Symmetric to Pikuri::Agent::Configurator#on_close — same LIFO + per-handler-rescue + idempotent semantics — but available post-construction, so an Extension‘s bind(agent) can install per-agent cleanup that’s keyed to this specific agent rather than the parent.
688 689 690 691 692 693 |
# File 'lib/pikuri/agent.rb', line 688 def on_close(&blk) raise ArgumentError, 'on_close requires a block' unless block_given? @on_close_handlers << blk nil end |
#run_loop(user_message:) ⇒ nil
Run the agent loop for a single user turn. Emits an Pikuri::Agent::Event::UserTurn with mid_loop: false, resets the step-budget and cancellation controls (so a stale state from a prior turn doesn’t poison this one), and forwards user_message to #chat via ask. Returns nil; rendering and any other observable output is the listeners’ responsibility.
If the step_limit control trips during ask, the rescue branch emits an Pikuri::Agent::Event::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.
If the cancellable control trips during ask, the rescue branch emits an Pikuri::Agent::Event::Cancelled and re-raises the Cancelled exception. No synthesizer fallback runs — see the “Cancellation rescue” section in the class header.
Subsequent calls keep building on the same chat history, so the model sees full multi-turn context.
608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 |
# File 'lib/pikuri/agent.rb', line 608 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.emit(Event::UserTurn.new(content: , mid_loop: false)) @step_limit&.reset! @cancellable&.reset! if @streaming @chat.ask(, &self.class.streaming_block(listeners: @listeners, cancellable: @cancellable)) else @chat.ask() end nil rescue Control::Cancellable::Cancelled @listeners.emit(Event::Cancelled.new) raise rescue Control::StepLimit::Exceeded => e @listeners.emit(Event::FallbackNotice.new( reason: "agent exhausted #{e.max_steps} steps; synthesizing answer from gathered evidence" )) # 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_chat = RubyLLM.chat(**@transport.to_h) # Defensive step limit on the synth: the synth has no # tools so it should never trip +before_tool_call+, but # guarding the budget anyway means a buggy provider that # somehow returns a tool call doesn't loop forever. synth_step_limit = @step_limit && Control::StepLimit.new(max: 1) @synth_answer = Synthesizer.run( chat: synth_chat, parent_messages: @chat., user_message: , listeners: @listeners.for_sub_agent(name: synth_name), step_limit: synth_step_limit, cancellable: @cancellable, streaming: @streaming ) nil end |
#to_s ⇒ String
Short, single-line config dump suitable for a startup banner or a debug print.
727 728 729 |
# File 'lib/pikuri/agent.rb', line 727 def to_s "Agent(model=#{model}, tools=#{@tools.size}, listeners=#{@listeners})" end |