pikuri-core 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/pikuri/agent.rb CHANGED
@@ -48,8 +48,8 @@ module Pikuri
48
48
  # and gets a fresh +step_limit+ at +max: 1+ (defensive — the
49
49
  # synth has no tools and shouldn't trip it). The synth's
50
50
  # answer becomes the value reported by
51
- # {#last_assistant_content}, so callers (notably
52
- # {Tool::SubAgent}) still get a usable reply.
51
+ # {#last_assistant_content}, so callers (notably the +agent+ tool
52
+ # from +pikuri-subagents+) still get a usable reply.
53
53
  #
54
54
  # == Cancellation rescue
55
55
  #
@@ -94,8 +94,16 @@ module Pikuri
94
94
  # queue is drained on every +after_tool_result+, each item
95
95
  # appended as a +role: :user+ message and emitted as
96
96
  # {Event::UserTurn} with +mid_loop: true+
97
+ # @param on_user_message [Proc, nil] when set, called with each
98
+ # drained interloper +content+ String *after* it is appended
99
+ # to the chat — the per-turn {Extension#on_user_message}
100
+ # dispatch (prefetch + recording). Threaded through here rather
101
+ # than fired inline so {Synthesizer.run}, which reuses this
102
+ # wiring without an interloper or memory, simply passes +nil+.
103
+ # Only consulted when +interloper+ is also set.
97
104
  # @return [void]
98
- def self.wire_chat(chat, listeners:, step_limit: nil, cancellable: nil, interloper: nil)
105
+ def self.wire_chat(chat, listeners:, step_limit: nil, cancellable: nil, interloper: nil,
106
+ on_user_message: nil)
99
107
  chat.after_message do |msg|
100
108
  emit_after_message(msg, listeners)
101
109
  end
@@ -106,7 +114,7 @@ module Pikuri
106
114
  end
107
115
  chat.after_tool_result do |result|
108
116
  listeners.emit(Event::ToolResult.new(content: result))
109
- drain_interloper(interloper, chat, listeners) if interloper
117
+ drain_interloper(interloper, chat, listeners, on_user_message) if interloper
110
118
  end
111
119
  end
112
120
 
@@ -216,18 +224,29 @@ module Pikuri
216
224
 
217
225
  # Drain the interloper queue: for each pending item, append a
218
226
  # +role: :user+ message to the chat history so the next
219
- # round-trip sees it, then emit an {Event::UserTurn} with
220
- # +mid_loop: true+ to the listener stream so renderers see
221
- # the injection.
227
+ # round-trip sees it, emit an {Event::UserTurn} with
228
+ # +mid_loop: true+ to the listener stream so renderers see the
229
+ # injection, then run the per-turn {Extension#on_user_message}
230
+ # dispatch (so mid-loop injections are prefetched + recorded
231
+ # exactly like initial turns).
232
+ #
233
+ # The dispatch runs *after* the +:user+ append so any
234
+ # +<memory-context>+ it injects lands as a +:system+ message
235
+ # right behind the user turn it annotates — the same
236
+ # append-at-the-tail ordering {#run_loop} produces for initial
237
+ # turns.
222
238
  #
223
239
  # @param interloper [Control::Interloper]
224
240
  # @param chat [RubyLLM::Chat]
225
241
  # @param listeners [ListenerList]
242
+ # @param on_user_message [Proc, nil] per-content dispatch; +nil+
243
+ # skips it (e.g. an interloper with no memory extension wired)
226
244
  # @return [void]
227
- def self.drain_interloper(interloper, chat, listeners)
245
+ def self.drain_interloper(interloper, chat, listeners, on_user_message = nil)
228
246
  interloper.drain!.each do |content|
229
247
  chat.add_message(role: :user, content: content)
230
248
  listeners.emit(Event::UserTurn.new(content: content, mid_loop: true))
249
+ on_user_message&.call(content)
231
250
  end
232
251
  end
233
252
  private_class_method :drain_interloper
@@ -332,14 +351,16 @@ module Pikuri
332
351
  # set. Typically derived by +bin/pikuri-chat+ from its
333
352
  # configured +openai_api_base+; leave +nil+ when the
334
353
  # configured server is anything other than llama.cpp.
335
- # @param name [String] identifier for this agent. Empty for
336
- # the main agent; sub-agents get monotonic hierarchical
337
- # names like +"sub_agent 0"+, +"sub_agent 1"+,
338
- # +"sub_agent 0_0"+, ... generated by {Tool::SubAgent} from
339
- # the parent's name + a per-parent counter. Forwarded to
340
- # listeners through {ListenerList#for_sub_agent} so name-
341
- # aware ones (notably {Listener::TokenLog}) can tag their
342
- # output.
354
+ # @param id [String] unique identifier for this agent. Empty
355
+ # for the main agent; sub-agents get persona-rooted ids
356
+ # like +"researcher 0"+, +"researcher 1"+, +"file_miner 0"+, ...
357
+ # generated by the +agent+ tool from +pikuri-subagents+ from
358
+ # the persona name + a per-persona counter. Forwarded to
359
+ # listeners through {ListenerList#for_sub_agent} so id-aware
360
+ # ones (notably {Listener::TokenLog}) can tag their output.
361
+ # The word "id" is deliberate — "name" is reserved throughout
362
+ # the codebase for the persona-name load (the value the LLM
363
+ # picks in the +agent+ tool's +name:+ argument).
343
364
  # @param streaming [Boolean] opt into chunk-level streaming.
344
365
  # When +true+, {#run_loop} passes the block returned by
345
366
  # {.streaming_block} to +Chat#ask+, and ruby_llm requests
@@ -348,25 +369,24 @@ module Pikuri
348
369
  # the listener stream as they arrive. When +false+ (the
349
370
  # default), +Chat#ask+ runs in single-shot mode and only
350
371
  # the message-level {Event::Thinking} / {Event::Assistant}
351
- # bookends fire from +after_message+. Read by
352
- # {Tool::SubAgent} so spawned sub-agents inherit the same
353
- # mode without an extra kwarg.
372
+ # bookends fire from +after_message+. Read by the +agent+
373
+ # tool from +pikuri-subagents+ so spawned sub-agents inherit
374
+ # the same mode without an extra kwarg.
354
375
  # @yield [Configurator] yields a {Configurator} that collects
355
376
  # tools (via {Configurator#add_tool} / {Configurator#add_tools}),
356
377
  # listeners (via {Configurator#add_listener} /
357
378
  # {Configurator#add_listeners}), system-prompt snippets (via
358
379
  # {Configurator#append_system_prompt}), extension instances
359
380
  # (via {Configurator#add_extension} — which fires +configure+
360
- # immediately), close handlers (via {Configurator#on_close}),
361
- # and an optional +sub_agent+ tool (via
362
- # {Configurator#allow_sub_agent}). The Configurator is the
363
- # *only* path for adding any of these there are no parallel
364
- # ctor kwargs. The block is optional; an agent constructed
365
- # without one has no tools, no listeners, no extensions.
381
+ # immediately), and close handlers (via
382
+ # {Configurator#on_close}). The Configurator is the *only*
383
+ # path for adding any of these — there are no parallel ctor
384
+ # kwargs. The block is optional; an agent constructed without
385
+ # one has no tools, no listeners, no extensions.
366
386
  # @return [Agent]
367
387
  def initialize(transport:, system_prompt:,
368
388
  step_limit: nil, cancellable: nil, interloper: nil,
369
- context_window: nil, llama_probe_url: nil, name: '',
389
+ context_window: nil, llama_probe_url: nil, id: '',
370
390
  streaming: false,
371
391
  &block)
372
392
  @transport = transport.model ? transport : transport.with(model: RubyLLM.config.default_model)
@@ -376,95 +396,35 @@ module Pikuri
376
396
  @system_prompt = system_prompt
377
397
  @step_limit = step_limit
378
398
  @interloper = interloper
379
- @name = name
399
+ @id = id
380
400
  @streaming = streaming
381
401
  @synth_answer = nil
382
402
  @on_close_handlers = []
383
-
384
- # Single Configurator funnel for everything the block adds —
385
- # tools, listeners, system-prompt snippets, extensions
386
- # (both newly-configured via #add_extension and inherited
387
- # via #inherit_extensions for sub-agents), on_close handlers,
388
- # and the sub-agent request. See IDEAS.md §"Extension protocol
389
- # design".
390
- configurator = Configurator.new(
391
- transport: @transport,
392
- system_prompt_base: system_prompt,
393
- name: @name,
394
- streaming: @streaming,
395
- step_limit: @step_limit,
396
- cancellable: @cancellable,
397
- interloper: @interloper
398
- )
399
-
400
- block&.call(configurator)
401
-
402
- @tools = configurator.tools.dup
403
- @listeners = ListenerList.new(configurator.listeners)
404
- configurator.system_prompt_additions.each do |snippet|
405
- @system_prompt = "#{@system_prompt}\n\n#{snippet}"
403
+ # Stashed for {#run_configure}, which runs the failure-prone
404
+ # build phase below out of a separate method.
405
+ @block = block
406
+ @context_window = context_window
407
+ @llama_probe_url = llama_probe_url
408
+
409
+ # Register *before* the build phase so a mid-construction raise
410
+ # is still recoverable: extensions arm their cleanup via
411
+ # +c.on_close+ (which writes straight to +@on_close_handlers+,
412
+ # see {Configurator}), and the rescue below fires whatever was
413
+ # armed before the failure. On the happy path this registration
414
+ # is the at-exit backstop if the host forgets {#close}; an
415
+ # explicit {#close} unregisters, so the agent isn't pinned alive
416
+ # until process exit.
417
+ Pikuri::Finalizers.register(self)
418
+
419
+ begin
420
+ run_configure
421
+ rescue StandardError
422
+ # Half-built agent (e.g. an extension's +configure+ raised
423
+ # Cancelled mid-spawn). Fire the handlers armed so far, drop
424
+ # out of the registry, and re-raise — no partial state leaks.
425
+ close
426
+ raise
406
427
  end
407
- @on_close_handlers.concat(configurator.on_close_handlers)
408
- @extensions = configurator.extensions.dup
409
-
410
- @chat = RubyLLM.chat(**@transport.to_h)
411
- @chat.with_instructions(@system_prompt)
412
- @tools.each { |t| @chat.with_tool(t.to_ruby_llm_tool) }
413
-
414
- @context_window_cap = ContextWindowDetector.new(
415
- override: context_window,
416
- ruby_llm_reported: @chat.model.context_window,
417
- llama_probe_url: llama_probe_url
418
- ).detect
419
-
420
- self.class.wire_chat(
421
- @chat,
422
- listeners: @listeners,
423
- step_limit: @step_limit,
424
- cancellable: @cancellable,
425
- interloper: @interloper
426
- )
427
-
428
- # One-shot context-window cap: lets every listener that
429
- # cares (notably TokenLog) pick the value off the stream
430
- # before any Tokens event arrives.
431
- @listeners.emit(Event::ContextCap.new(cap: @context_window_cap))
432
-
433
- # Sub-agent tool: constructed *after* @tools is final and
434
- # @context_window_cap is set, so its snapshot of the parent's
435
- # tool list doesn't include itself (recursion guard) and the
436
- # cap can be threaded through to spawned sub-agents. The new
437
- # +Tool::SubAgent+ instance is appended to both +@tools+ and
438
- # +@chat+, so sub-agents inheriting via the snapshot still
439
- # get the surrounding tool set but never the +sub_agent+ tool
440
- # itself. See {Configurator#allow_sub_agent}.
441
- if configurator.sub_agent_request
442
- if @tools.any?(Tool::SubAgent)
443
- raise 'Tool::SubAgent must not be added via c.add_tool when c.allow_sub_agent ' \
444
- 'is used; Agent auto-registers it from the Configurator request.'
445
- end
446
-
447
- sub_tool = Tool::SubAgent.new(self, max_steps: configurator.sub_agent_request.max_steps)
448
- @tools << sub_tool
449
- @chat.with_tool(sub_tool.to_ruby_llm_tool)
450
- end
451
-
452
- # Bind sweep — each extension gets its chance to install
453
- # per-agent state (dynamic tools via #internal_add_tool,
454
- # per-agent close hooks via #on_close, etc.) now that the
455
- # chat is fully wired. See IDEAS.md §"Extension protocol
456
- # design" for what #configure vs #bind are each for.
457
- @extensions.each { |ext| ext.bind(self) }
458
-
459
- # Fallback cleanup: if the host forgets to call #close, the
460
- # at_exit hook fires it on process exit. Idempotent, so an
461
- # explicit close earlier makes this a no-op. The closure
462
- # captures self, which keeps the agent reachable until
463
- # process exit — fine for the handful of agents a typical
464
- # host creates; if pikuri grows a long-running host that
465
- # constructs many short-lived agents, switch to a single
466
- # process-global registry that close-then-removes.
467
- at_exit { close }
468
428
  end
469
429
 
470
430
  # @return [RubyLLM::Chat] underlying chat; the extension seam
@@ -474,19 +434,30 @@ module Pikuri
474
434
  # agent was constructed with — same model id / provider /
475
435
  # assume-model-exists flag passed to every +RubyLLM.chat+
476
436
  # call originating from this agent (the main chat, the
477
- # synthesizer rescue, the sub-agent tool). Read by
478
- # {Tool::SubAgent} so spawned sub-agents reuse the same
479
- # transport.
437
+ # synthesizer rescue, the +agent+ tool from
438
+ # +pikuri-subagents+). Read by extensions that need to spawn
439
+ # their own ruby_llm calls (e.g. MCP description synthesis,
440
+ # sub-agent delegation).
480
441
  attr_reader :transport
481
442
 
482
443
  # @return [Array<Tool>] this agent's tool list in declaration
483
- # order. Snapshotted by {Tool::SubAgent} so spawned
484
- # sub-agents inherit the parent's tools (minus the
485
- # sub-agent tool itself, which {#allow_sub_agent} appends
486
- # to +@tools+ only after the snapshot has been taken —
487
- # recursion guard).
444
+ # order. Read by extensions that filter against it (notably
445
+ # the +agent+ tool from +pikuri-subagents+, which picks the
446
+ # sub-agent's toolset from the parent's instances so any
447
+ # already-bound workspace/confirmer wiring travels along).
448
+ # Tools listed here are also the ones registered with
449
+ # ruby_llm — the parent LLM can call any of them. Compare
450
+ # with {#sub_agent_tools}.
488
451
  attr_reader :tools
489
452
 
453
+ # @return [Array<Tool>] tools registered via
454
+ # {Configurator#add_sub_agent_tool}, in declaration order.
455
+ # Invisible to the parent LLM (never sent to ruby_llm);
456
+ # available only to sub-agents whose persona +tool_names+
457
+ # match. See {Configurator}'s "Two tool pools" header for
458
+ # the trifecta-defense rationale.
459
+ attr_reader :sub_agent_tools
460
+
490
461
  # @return [String] resolved model id from {#transport}.
491
462
  # Convenience delegator for callers that don't need the
492
463
  # full transport bundle.
@@ -496,12 +467,10 @@ module Pikuri
496
467
 
497
468
  # @return [String] system prompt actually sent to the chat —
498
469
  # equal to the constructor's +system_prompt:+ argument plus
499
- # any snippets appended by extensions during
500
- # {Configurator#append_system_prompt} (Skills'
501
- # +<available_skills>+, MCP's +<available_mcps>+, ...).
502
- # {Tool::SubAgent} forwards this already-augmented value to
503
- # spawned sub-agents so they see the same advertisements
504
- # without re-running extension configure.
470
+ # any snippets appended via {Configurator#append_system_prompt}
471
+ # (extensions' +<available_skills>+ / +<available_mcps>+ /
472
+ # +<available_agents>+, ...). Not inherited by sub-agents —
473
+ # each persona owns its own system prompt verbatim.
505
474
  attr_reader :system_prompt
506
475
 
507
476
  # @return [ListenerList] the listener list attached to this
@@ -510,54 +479,55 @@ module Pikuri
510
479
 
511
480
  # @return [Control::StepLimit, nil] the step-budget control
512
481
  # this agent was constructed with, or +nil+ when none.
513
- # Read by {Tool::SubAgent} so spawned sub-agents derive
514
- # their own.
515
482
  attr_reader :step_limit
516
483
 
517
484
  # @return [Control::Cancellable, nil] the cancellation
518
485
  # control this agent was constructed with, or +nil+ when
519
- # none. Read by {Tool::SubAgent} so spawned sub-agents
520
- # share the same instance.
486
+ # none. Read by extensions that propagate cancellation to
487
+ # their own LLM calls (e.g. the +agent+ tool from
488
+ # +pikuri-subagents+ shares it with spawned sub-agents so
489
+ # one Ctrl+C stops the tree).
521
490
  attr_reader :cancellable
522
491
 
523
492
  # @return [Control::Interloper, nil] the mid-loop user-input
524
493
  # control this agent was constructed with, or +nil+ when
525
- # none. Not propagated to sub-agents — see
526
- # {Control::Interloper#for_sub_agent}.
494
+ # none.
527
495
  attr_reader :interloper
528
496
 
529
- # @return [String] this agent's identifier — empty for the
530
- # main agent; for sub-agents, the hierarchical id assigned
531
- # by {Tool::SubAgent} (e.g. +"sub_agent 0"+,
532
- # +"sub_agent 1"+, +"sub_agent 0_0"+). Read by the
533
- # sub-agent tool so spawned sub-agents prefix their own
534
- # names with this one, and propagated to listeners via
535
- # {ListenerList#for_sub_agent} so name-aware ones can tag
536
- # output.
537
- attr_reader :name
497
+ # @return [String] this agent's unique identifier — empty for
498
+ # the main agent; for sub-agents, the persona-rooted id
499
+ # assigned by the +agent+ tool from +pikuri-subagents+ (e.g.
500
+ # +"researcher 0"+, +"researcher 1"+, +"file_miner 0"+).
501
+ # Propagated to listeners via {ListenerList#for_sub_agent(id:)}
502
+ # so id-aware ones can tag output. Distinct from the persona's
503
+ # +name+ (the value the LLM picks in the +agent+ tool's
504
+ # +name:+ argument).
505
+ attr_reader :id
538
506
 
539
507
  # @return [Boolean] +true+ when this agent opted into
540
508
  # chunk-level streaming (see the +streaming:+ kwarg on
541
- # {#initialize}); +false+ otherwise. Read by
542
- # {Tool::SubAgent} so spawned sub-agents inherit the same
543
- # mode.
509
+ # {#initialize}); +false+ otherwise. Read by extensions that
510
+ # spawn their own ruby_llm calls (notably the +agent+ tool
511
+ # from +pikuri-subagents+, so spawned sub-agents inherit the
512
+ # same mode).
544
513
  attr_reader :streaming
545
514
 
546
515
  # @return [Array<Extension>] extension instances bound to this
547
- # agent — added via {Configurator#add_extension} (new — runs
548
- # +configure+ now and binds later) or {Configurator#inherit_extensions}
549
- # (sub-agent inheritance skips +configure+, just binds), both
550
- # inside the +Agent.new+ block. Read by {Tool::SubAgent} so
551
- # spawned sub-agents inherit the parent's extension list and
552
- # re-bind them via the bind sweep.
516
+ # agent — added via {Configurator#add_extension} inside the
517
+ # +Agent.new+ block. Each instance's +configure+ runs during
518
+ # the block and its +bind+ runs at the end of
519
+ # {#initialize}, once per registration (so once per parent
520
+ # agent in the typical setup; sub-agents do not inherit
521
+ # extensions).
553
522
  attr_reader :extensions
554
523
 
555
524
  # @return [Integer, nil] context-window cap resolved by
556
525
  # {ContextWindowDetector} at construction time. +nil+ when
557
526
  # no source produced a value (custom local model with no
558
527
  # override and no reachable llama.cpp +/props+). Read by
559
- # {Tool::SubAgent} so spawned sub-agents inherit the same
560
- # cap without re-probing.
528
+ # extensions that spawn their own ruby_llm calls (notably
529
+ # the +agent+ tool from +pikuri-subagents+, so spawned
530
+ # sub-agents inherit the same cap without re-probing).
561
531
  attr_reader :context_window_cap
562
532
 
563
533
  # Final assistant message content for the most recent
@@ -610,13 +580,23 @@ module Pikuri
610
580
  if user_message.nil? || user_message.to_s.strip.empty?
611
581
 
612
582
  @synth_answer = nil
613
- @listeners.emit(Event::UserTurn.new(content: user_message, mid_loop: false))
614
583
  @step_limit&.reset!
615
584
  @cancellable&.reset!
585
+ # Append the user turn, emit it, then run the memory dispatch — so
586
+ # any <memory-context> the dispatch injects lands as a :system
587
+ # message *after* the user turn it annotates (append-only at the
588
+ # tail; see {#dispatch_ext_on_user_message}). `ask` would bundle the
589
+ # user-message append with completion atomically, leaving no seam to
590
+ # inject between them, so the two halves run explicitly here:
591
+ # add_message + complete (the exact pair `ask` is sugar for). A raw
592
+ # String content matches the interloper drain path.
593
+ @chat.add_message(role: :user, content: user_message)
594
+ @listeners.emit(Event::UserTurn.new(content: user_message, mid_loop: false))
595
+ dispatch_ext_on_user_message(user_message)
616
596
  if @streaming
617
- @chat.ask(user_message, &self.class.streaming_block(listeners: @listeners, cancellable: @cancellable))
597
+ @chat.complete(&self.class.streaming_block(listeners: @listeners, cancellable: @cancellable))
618
598
  else
619
- @chat.ask(user_message)
599
+ @chat.complete
620
600
  end
621
601
  nil
622
602
  rescue Control::Cancellable::Cancelled
@@ -629,14 +609,14 @@ module Pikuri
629
609
 
630
610
  # Synth runs under this agent's identity but on a fresh
631
611
  # chat with a different system prompt, so it gets a
632
- # distinct +_synthesizer+ suffix on the name — same +_+
612
+ # distinct +_synthesizer+ suffix on the id — same +_+
633
613
  # separator the sub-agent generator uses, so main becomes
634
- # +"synthesizer"+ and a sub-agent +"sub_agent 0"+ becomes
635
- # +"sub_agent 0_synthesizer"+. Any +TokenLog+ in the list
614
+ # +"synthesizer"+ and a sub-agent +"researcher 0"+ becomes
615
+ # +"researcher 0_synthesizer"+. Any +TokenLog+ in the list
636
616
  # tags the synth's prompt under that bracket so it's
637
617
  # obvious from the log which turns were the rescue rather
638
618
  # than the original loop.
639
- synth_name = @name.empty? ? 'synthesizer' : "#{@name}_synthesizer"
619
+ synth_id = @id.empty? ? 'synthesizer' : "#{@id}_synthesizer"
640
620
  synth_chat = RubyLLM.chat(**@transport.to_h)
641
621
  # Defensive step limit on the synth: the synth has no
642
622
  # tools so it should never trip +before_tool_call+, but
@@ -647,7 +627,7 @@ module Pikuri
647
627
  chat: synth_chat,
648
628
  parent_messages: @chat.messages,
649
629
  user_message: user_message,
650
- listeners: @listeners.for_sub_agent(name: synth_name),
630
+ listeners: @listeners.for_sub_agent(id: synth_id),
651
631
  step_limit: synth_step_limit,
652
632
  cancellable: @cancellable,
653
633
  streaming: @streaming
@@ -670,6 +650,10 @@ module Pikuri
670
650
  return if @closed
671
651
 
672
652
  @closed = true
653
+ # Drop out of the process-global registry first: a deliberate
654
+ # close means this agent no longer needs the at-exit fallback,
655
+ # and removing the reference lets it be garbage-collected.
656
+ Pikuri::Finalizers.unregister(self)
673
657
  @on_close_handlers.reverse_each do |handler|
674
658
  handler.call
675
659
  rescue StandardError => e
@@ -706,9 +690,10 @@ module Pikuri
706
690
  # +Pikuri::Tool+ entirely."
707
691
  #
708
692
  # The added tool does NOT enter +@tools+, only +@chat+'s tool
709
- # list. {Tool::SubAgent} therefore cannot snapshot it (which is
710
- # the whole pointactivation is strictly per-agent, see
711
- # IDEAS.md §"Per-agent activation, no propagation").
693
+ # list. Sub-agents (the +agent+ tool from +pikuri-subagents+)
694
+ # therefore cannot snapshot it which is the whole point:
695
+ # activation is strictly per-agent, see IDEAS.md §"Per-agent
696
+ # activation, no propagation".
712
697
  #
713
698
  # @param ruby_llm_tool [Class] subclass of +RubyLLM::Tool+
714
699
  # @return [void]
@@ -721,11 +706,119 @@ module Pikuri
721
706
  #
722
707
  # @example
723
708
  # agent.to_s
724
- # # => "Agent(model=qwen3-35b, tools=4, listeners=[Terminal])"
709
+ # # => "Agent(id=, model=qwen3-35b, tools=4, listeners=[Terminal])"
725
710
  #
726
711
  # @return [String]
727
712
  def to_s
728
- "Agent(model=#{model}, tools=#{@tools.size}, listeners=#{@listeners})"
713
+ "Agent(id=#{@id}, model=#{model}, tools=#{@tools.size}, listeners=#{@listeners})"
714
+ end
715
+
716
+ private
717
+
718
+ # The failure-prone build phase, split out of {#initialize} so the
719
+ # constructor can wrap it in a rescue and self-heal. Funnels the
720
+ # +Agent.new+ block through a single {Configurator} — tools,
721
+ # listeners, system-prompt snippets, extensions, and +on_close+
722
+ # handlers — then wires the chat and runs the extension +bind+
723
+ # sweep. The Configurator's +on_close_sink:+ is +@on_close_handlers+
724
+ # itself, so a handler an extension arms via +c.on_close+ is live on
725
+ # the agent the instant it's registered — that's what lets the
726
+ # constructor's rescue close a half-built agent.
727
+ #
728
+ # @return [void]
729
+ def run_configure
730
+ configurator = Configurator.new(
731
+ transport: @transport,
732
+ system_prompt_base: @system_prompt,
733
+ id: @id,
734
+ streaming: @streaming,
735
+ step_limit: @step_limit,
736
+ cancellable: @cancellable,
737
+ interloper: @interloper,
738
+ on_close_sink: @on_close_handlers
739
+ )
740
+
741
+ @block&.call(configurator)
742
+
743
+ @tools = configurator.tools.dup
744
+ @sub_agent_tools = configurator.sub_agent_tools.dup
745
+ @listeners = ListenerList.new(configurator.listeners)
746
+ configurator.system_prompt_additions.each do |snippet|
747
+ @system_prompt = "#{@system_prompt}\n\n#{snippet}"
748
+ end
749
+ @extensions = configurator.extensions.dup
750
+
751
+ @chat = RubyLLM.chat(**@transport.to_h)
752
+ @chat.with_instructions(@system_prompt)
753
+ @tools.each { |t| @chat.with_tool(t.to_ruby_llm_tool) }
754
+
755
+ @context_window_cap = ContextWindowDetector.new(
756
+ override: @context_window,
757
+ ruby_llm_reported: @chat.model.context_window,
758
+ llama_probe_url: @llama_probe_url,
759
+ model_id: @chat.model.id
760
+ ).detect
761
+
762
+ self.class.wire_chat(
763
+ @chat,
764
+ listeners: @listeners,
765
+ step_limit: @step_limit,
766
+ cancellable: @cancellable,
767
+ interloper: @interloper,
768
+ on_user_message: method(:dispatch_ext_on_user_message)
769
+ )
770
+
771
+ # One-shot context-window cap: lets every listener that
772
+ # cares (notably TokenLog) pick the value off the stream
773
+ # before any Tokens event arrives.
774
+ @listeners.emit(Event::ContextCap.new(cap: @context_window_cap))
775
+
776
+ # Bind sweep — each extension gets its chance to install
777
+ # per-agent state (dynamic tools via #internal_add_tool,
778
+ # per-agent close hooks via #on_close, etc.) now that the
779
+ # chat is fully wired. See IDEAS.md §"Extension protocol
780
+ # design" for what #configure vs #bind are each for.
781
+ @extensions.each { |ext| ext.bind(self) }
782
+ end
783
+
784
+ # Fire the per-turn {Extension#on_user_message} hook on every
785
+ # extension that defines it, appending any returned
786
+ # +<memory-context>+ block to the chat as a +role: :system+
787
+ # message right after the user turn it annotates (callers append
788
+ # the +:user+ message first; this runs last). The system role is
789
+ # load-bearing — it tags the block as recalled reference (not new
790
+ # input) and keeps it excludable from a later extraction pass.
791
+ # See {Extension#on_user_message}.
792
+ #
793
+ # Each injected block also emits an {Event::SystemInjected} at
794
+ # this site, so the listener stream mirrors the log growth (the
795
+ # Terminal renders it; otherwise an injection would be invisible
796
+ # except as a downstream echo in the assistant's reasoning).
797
+ #
798
+ # Private and the single place the chat log grows by a memory
799
+ # block — keeps "what mutates the log, when" one grep in this
800
+ # file. Fired from {#run_loop} (initial turn) and, via the
801
+ # +on_user_message:+ proc threaded into {.wire_chat}, from
802
+ # {.drain_interloper} (mid-loop interlopers). Called on every
803
+ # extension unconditionally — same as {Extension#configure} /
804
+ # {Extension#bind}: the hook is part of the protocol and the
805
+ # {Extension} module supplies a no-op default, so any extension
806
+ # that includes the module responds. An extension is "opted out"
807
+ # by leaving the default in place (it returns +nil+, injecting
808
+ # nothing), not by omitting the method.
809
+ #
810
+ # @param content [String] the incoming user message
811
+ # @return [void]
812
+ def dispatch_ext_on_user_message(content)
813
+ @extensions.each do |ext|
814
+ message = ext.on_user_message(self, content)
815
+ next unless message.is_a?(String) && !message.strip.empty?
816
+
817
+ block = message.strip
818
+ @chat.add_message(role: :system, content: block)
819
+ @listeners.emit(Event::SystemInjected.new(content: block))
820
+ end
821
+ nil
729
822
  end
730
823
  end
731
824
  end