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.
- checksums.yaml +4 -4
- data/README.md +10 -0
- data/lib/pikuri/agent/chat_transport.rb +6 -5
- data/lib/pikuri/agent/configurator.rb +59 -77
- data/lib/pikuri/agent/context_window_detector.rb +70 -10
- data/lib/pikuri/agent/control/cancellable.rb +7 -17
- data/lib/pikuri/agent/control/interloper.rb +20 -23
- data/lib/pikuri/agent/control/step_limit.rb +0 -14
- data/lib/pikuri/agent/event.rb +15 -0
- data/lib/pikuri/agent/extension.rb +49 -23
- data/lib/pikuri/agent/listener/terminal.rb +5 -1
- data/lib/pikuri/agent/listener/token_log.rb +20 -21
- data/lib/pikuri/agent/listener_list.rb +7 -5
- data/lib/pikuri/agent/synthesizer.rb +2 -2
- data/lib/pikuri/agent.rb +257 -164
- data/lib/pikuri/file_type.rb +457 -0
- data/lib/pikuri/finalizers.rb +118 -0
- data/lib/pikuri/paths.rb +29 -0
- data/lib/pikuri/subprocess.rb +45 -12
- data/lib/pikuri/tool/parameters.rb +64 -3
- data/lib/pikuri/tool.rb +15 -7
- data/lib/pikuri/version.rb +1 -1
- metadata +5 -3
- data/lib/pikuri/tool/sub_agent.rb +0 -150
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
|
-
#
|
|
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,
|
|
220
|
-
# +mid_loop: true+ to the listener stream so renderers see
|
|
221
|
-
# the
|
|
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
|
|
336
|
-
# the main agent; sub-agents get
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
# the
|
|
340
|
-
# listeners through {ListenerList#for_sub_agent} so
|
|
341
|
-
#
|
|
342
|
-
#
|
|
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
|
-
#
|
|
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
|
|
361
|
-
#
|
|
362
|
-
#
|
|
363
|
-
#
|
|
364
|
-
#
|
|
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,
|
|
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
|
-
@
|
|
399
|
+
@id = id
|
|
380
400
|
@streaming = streaming
|
|
381
401
|
@synth_answer = nil
|
|
382
402
|
@on_close_handlers = []
|
|
383
|
-
|
|
384
|
-
#
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
#
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
478
|
-
#
|
|
479
|
-
#
|
|
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.
|
|
484
|
-
#
|
|
485
|
-
# sub-agent
|
|
486
|
-
#
|
|
487
|
-
#
|
|
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
|
|
500
|
-
#
|
|
501
|
-
# +<
|
|
502
|
-
#
|
|
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
|
|
520
|
-
#
|
|
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.
|
|
526
|
-
# {Control::Interloper#for_sub_agent}.
|
|
494
|
+
# none.
|
|
527
495
|
attr_reader :interloper
|
|
528
496
|
|
|
529
|
-
# @return [String] this agent's identifier — empty for
|
|
530
|
-
# main agent; for sub-agents, the
|
|
531
|
-
# by
|
|
532
|
-
# +"
|
|
533
|
-
#
|
|
534
|
-
#
|
|
535
|
-
#
|
|
536
|
-
#
|
|
537
|
-
attr_reader :
|
|
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
|
-
#
|
|
543
|
-
#
|
|
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}
|
|
548
|
-
# +
|
|
549
|
-
#
|
|
550
|
-
#
|
|
551
|
-
#
|
|
552
|
-
#
|
|
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
|
-
#
|
|
560
|
-
#
|
|
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.
|
|
597
|
+
@chat.complete(&self.class.streaming_block(listeners: @listeners, cancellable: @cancellable))
|
|
618
598
|
else
|
|
619
|
-
@chat.
|
|
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
|
|
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 +"
|
|
635
|
-
# +"
|
|
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
|
-
|
|
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(
|
|
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.
|
|
710
|
-
#
|
|
711
|
-
# IDEAS.md §"Per-agent
|
|
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
|