pikuri-core 0.0.3
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 +7 -0
- data/README.md +67 -0
- data/lib/pikuri/agent/chat_transport.rb +41 -0
- data/lib/pikuri/agent/configurator.rb +270 -0
- data/lib/pikuri/agent/context_window_detector.rb +111 -0
- data/lib/pikuri/agent/control/cancellable.rb +128 -0
- data/lib/pikuri/agent/control/interloper.rb +167 -0
- data/lib/pikuri/agent/control/step_limit.rb +93 -0
- data/lib/pikuri/agent/control.rb +45 -0
- data/lib/pikuri/agent/event.rb +190 -0
- data/lib/pikuri/agent/extension.rb +82 -0
- data/lib/pikuri/agent/listener/in_memory_event_list.rb +34 -0
- data/lib/pikuri/agent/listener/rate_limited.rb +172 -0
- data/lib/pikuri/agent/listener/terminal.rb +264 -0
- data/lib/pikuri/agent/listener/token_log.rb +216 -0
- data/lib/pikuri/agent/listener.rb +54 -0
- data/lib/pikuri/agent/listener_list.rb +102 -0
- data/lib/pikuri/agent/synthesizer.rb +145 -0
- data/lib/pikuri/agent.rb +731 -0
- data/lib/pikuri/subprocess.rb +166 -0
- data/lib/pikuri/tool/calculator.rb +82 -0
- data/lib/pikuri/tool/fetch.rb +171 -0
- data/lib/pikuri/tool/parameters.rb +314 -0
- data/lib/pikuri/tool/scraper/fetch_error.rb +16 -0
- data/lib/pikuri/tool/scraper/html.rb +285 -0
- data/lib/pikuri/tool/scraper/pdf.rb +54 -0
- data/lib/pikuri/tool/scraper/simple.rb +183 -0
- data/lib/pikuri/tool/search/brave.rb +184 -0
- data/lib/pikuri/tool/search/duckduckgo.rb +196 -0
- data/lib/pikuri/tool/search/engines.rb +163 -0
- data/lib/pikuri/tool/search/exa.rb +217 -0
- data/lib/pikuri/tool/search/rate_limiter.rb +92 -0
- data/lib/pikuri/tool/search/result.rb +29 -0
- data/lib/pikuri/tool/sub_agent.rb +150 -0
- data/lib/pikuri/tool/web_scrape.rb +121 -0
- data/lib/pikuri/tool/web_search.rb +38 -0
- data/lib/pikuri/tool.rb +118 -0
- data/lib/pikuri/url_cache.rb +112 -0
- data/lib/pikuri/version.rb +10 -0
- data/lib/pikuri-core.rb +177 -0
- data/prompts/pikuri-chat.txt +15 -0
- metadata +251 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
class Agent
|
|
5
|
+
module Control
|
|
6
|
+
# Mid-loop user-input queue. A host (TUI, web client)
|
|
7
|
+
# constructs an +Interloper+, hands it to {Agent#initialize}
|
|
8
|
+
# via the +interloper:+ kwarg, and calls
|
|
9
|
+
# {#inject_user_message} from any thread while the agent
|
|
10
|
+
# is running. The +Agent+ drains the queue at the next
|
|
11
|
+
# +after_tool_result+ boundary — the only point inside
|
|
12
|
+
# ruby_llm's loop where the conversation state is
|
|
13
|
+
# consistent — and emits each item into the chat history
|
|
14
|
+
# plus the listener stream. The agent's next round-trip
|
|
15
|
+
# then sees the injected user message and reacts to it on
|
|
16
|
+
# its own.
|
|
17
|
+
#
|
|
18
|
+
# +Interloper+ is groundwork for downstream TUI/web hosts;
|
|
19
|
+
# the bundled +bin/pikuri-*+ entry-point scripts do *not*
|
|
20
|
+
# wire one up, since they keep stdin synchronous and have
|
|
21
|
+
# no way for a user to type while a turn is in flight.
|
|
22
|
+
# Downstream hosts that *do* run the agent on a worker
|
|
23
|
+
# thread can wire one in with no other changes to pikuri.
|
|
24
|
+
#
|
|
25
|
+
# == Delivery boundary and side effects
|
|
26
|
+
#
|
|
27
|
+
# When the +Agent+'s +after_tool_result+ wiring fires, it
|
|
28
|
+
# calls {#drain!} on the interloper (if any) and, for each
|
|
29
|
+
# returned item:
|
|
30
|
+
#
|
|
31
|
+
# 1. Appends a +role: :user+ message to the chat history so
|
|
32
|
+
# the next +complete+ round-trip's request includes it.
|
|
33
|
+
# 2. Emits +Event::UserTurn(content:, mid_loop: true)+
|
|
34
|
+
# through the listener stream so other listeners
|
|
35
|
+
# (Terminal renderer, in-memory recorder, future logging)
|
|
36
|
+
# see the injection as a normal +UserTurn+ event with the
|
|
37
|
+
# +mid_loop:+ flag set.
|
|
38
|
+
#
|
|
39
|
+
# Controls do not respond to events; the +Agent+ pokes
|
|
40
|
+
# {Control::StepLimit#reset!} and {Control::Cancellable#reset!}
|
|
41
|
+
# only at the start of each turn — never on a mid-loop
|
|
42
|
+
# injection — so the "cancel-then-inject" hazard and the
|
|
43
|
+
# "refresh-budget-by-injecting" hazard cannot arise.
|
|
44
|
+
#
|
|
45
|
+
# == Boundary caveats
|
|
46
|
+
#
|
|
47
|
+
# The delivery point is +after_tool_result+, *not* the LLM
|
|
48
|
+
# HTTP call. Injections placed while the model is
|
|
49
|
+
# mid-response take effect on the *next* round-trip — by
|
|
50
|
+
# the time the queue drains, the model has already
|
|
51
|
+
# committed to whichever tool calls were in that response.
|
|
52
|
+
# The agent therefore typically observes an injection at
|
|
53
|
+
# the tool-batch boundary *after* the one during which the
|
|
54
|
+
# host called {#inject_user_message}. This is the same
|
|
55
|
+
# "gentle" semantic that {Control::Cancellable} promises
|
|
56
|
+
# and is the cleanest cross-provider point: no in-flight
|
|
57
|
+
# subprocess, no half-applied write, no half-built response.
|
|
58
|
+
#
|
|
59
|
+
# == Thread safety
|
|
60
|
+
#
|
|
61
|
+
# {#inject_user_message}, {#peek}, {#pending?}, and
|
|
62
|
+
# {#drain!} are safe to call from any thread; the internal
|
|
63
|
+
# queue is a +Mutex+-guarded +Array+. The +Agent+'s drain
|
|
64
|
+
# runs on the run thread (whatever thread invoked
|
|
65
|
+
# +Chat#ask+). +Mutex+ was chosen over +Thread::Queue+
|
|
66
|
+
# because +Thread::Queue+ exposes no snapshot read, and
|
|
67
|
+
# {#peek} is part of the surface (the host wants to render
|
|
68
|
+
# "feedback received, will deliver shortly" in its UI
|
|
69
|
+
# before the agent actually consumes the injection).
|
|
70
|
+
#
|
|
71
|
+
# == Sub-agent semantics
|
|
72
|
+
#
|
|
73
|
+
# {#for_sub_agent} returns +nil+. Sub-agents are private to
|
|
74
|
+
# the parent agent; the host has no handle to them, so a
|
|
75
|
+
# child +Interloper+ would be unreachable. The sub-agent's
|
|
76
|
+
# {Agent#initialize} simply receives +interloper: nil+ from
|
|
77
|
+
# {Tool::SubAgent}, which is its default. The behavior
|
|
78
|
+
# contrasts with {Control::Cancellable}, which shares its
|
|
79
|
+
# instance by reference so the parent's signal propagates
|
|
80
|
+
# to children — cancellation is a global "stop the whole
|
|
81
|
+
# tree" event, whereas injection is a directed "talk to the
|
|
82
|
+
# main agent" event.
|
|
83
|
+
class Interloper
|
|
84
|
+
def initialize
|
|
85
|
+
@mutex = Mutex.new
|
|
86
|
+
@items = []
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Push +content+ onto the delivery queue. Safe from any
|
|
90
|
+
# thread; the queue is +Mutex+-guarded.
|
|
91
|
+
#
|
|
92
|
+
# @param content [String] non-blank user-supplied text
|
|
93
|
+
# @raise [ArgumentError] if +content+ is +nil+, empty, or
|
|
94
|
+
# whitespace-only — same rule as {Agent#run_loop}'s
|
|
95
|
+
# +user_message:+ argument, since an empty injection
|
|
96
|
+
# would poison the chat history just as a blank turn
|
|
97
|
+
# would
|
|
98
|
+
# @return [void]
|
|
99
|
+
def inject_user_message(content)
|
|
100
|
+
raise ArgumentError, "content must not be blank, got #{content.inspect}" \
|
|
101
|
+
if content.nil? || content.to_s.strip.empty?
|
|
102
|
+
|
|
103
|
+
@mutex.synchronize { @items << content }
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Non-destructive snapshot of the queue, in delivery
|
|
108
|
+
# order. Intended for hosts that want to render an
|
|
109
|
+
# "ongoing / pending" UI affordance ("3 messages waiting
|
|
110
|
+
# to deliver") in parallel with the agent's progress
|
|
111
|
+
# stream. Safe to call from any thread.
|
|
112
|
+
#
|
|
113
|
+
# @return [Array<String>] copy of the pending items;
|
|
114
|
+
# never shares state with the internal buffer
|
|
115
|
+
def peek
|
|
116
|
+
@mutex.synchronize { @items.dup }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Boolean] whether the queue currently holds at
|
|
120
|
+
# least one pending injection; observable from any
|
|
121
|
+
# thread
|
|
122
|
+
def pending?
|
|
123
|
+
@mutex.synchronize { !@items.empty? }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Atomically take and remove all pending items. Called by
|
|
127
|
+
# {Agent}'s +after_tool_result+ wiring; the +Agent+ then
|
|
128
|
+
# appends each item to the chat history and emits an
|
|
129
|
+
# {Event::UserTurn} with +mid_loop: true+ for each.
|
|
130
|
+
#
|
|
131
|
+
# Returns +[]+ when the queue is empty (the hot path —
|
|
132
|
+
# every +after_tool_result+ calls this).
|
|
133
|
+
#
|
|
134
|
+
# @return [Array<String>] items in delivery order; empty
|
|
135
|
+
# when the queue is empty
|
|
136
|
+
def drain!
|
|
137
|
+
@mutex.synchronize do
|
|
138
|
+
next [] if @items.empty?
|
|
139
|
+
|
|
140
|
+
items = @items.dup
|
|
141
|
+
@items.clear
|
|
142
|
+
items
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Sub-agent variant: +nil+, signalling to {Agent} (and
|
|
147
|
+
# transitively to {Tool::SubAgent}) that no +Interloper+
|
|
148
|
+
# should be wired on a spawned sub-agent. See the class
|
|
149
|
+
# header for the "host has no handle to sub-agents"
|
|
150
|
+
# rationale.
|
|
151
|
+
#
|
|
152
|
+
# @return [nil]
|
|
153
|
+
def for_sub_agent(**)
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [String] short label for {Agent#to_s}; reflects
|
|
158
|
+
# the pending-count so a debug print or banner can tell
|
|
159
|
+
# an idle interloper apart from one with queued items
|
|
160
|
+
def to_s
|
|
161
|
+
size = @mutex.synchronize { @items.size }
|
|
162
|
+
size.zero? ? 'Interloper' : "Interloper(#{size} pending)"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
class Agent
|
|
5
|
+
module Control
|
|
6
|
+
# Caps the number of tool calls per {Agent#run_loop}
|
|
7
|
+
# invocation. ruby_llm has no built-in step budget; the
|
|
8
|
+
# +Agent+ pokes {#tick!} on every +before_tool_call+
|
|
9
|
+
# callback and {#reset!} at the start of each turn. Once the
|
|
10
|
+
# counter exceeds the configured cap, {#tick!} raises
|
|
11
|
+
# {Exceeded}, the +Agent+ catches it, and the step-
|
|
12
|
+
# exhaustion synthesizer rescues to salvage a partial
|
|
13
|
+
# answer.
|
|
14
|
+
class StepLimit
|
|
15
|
+
# Raised by {#tick!} once tool-call count exceeds +max+.
|
|
16
|
+
# Carries the budget that was tripped so rescue clauses
|
|
17
|
+
# can include it in user-facing messages.
|
|
18
|
+
class Exceeded < StandardError
|
|
19
|
+
# @return [Integer]
|
|
20
|
+
attr_reader :max_steps
|
|
21
|
+
|
|
22
|
+
# @param max_steps [Integer]
|
|
23
|
+
def initialize(max_steps)
|
|
24
|
+
@max_steps = max_steps
|
|
25
|
+
super("Agent loop exceeded #{max_steps} steps")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Integer] the configured cap
|
|
30
|
+
attr_reader :max
|
|
31
|
+
|
|
32
|
+
# @param max [Integer] hard cap on tool-call rounds; must
|
|
33
|
+
# be positive
|
|
34
|
+
# @raise [ArgumentError] if +max+ is zero or negative
|
|
35
|
+
def initialize(max:)
|
|
36
|
+
raise ArgumentError, "max must be positive, got #{max}" if max <= 0
|
|
37
|
+
|
|
38
|
+
@max = max
|
|
39
|
+
@step = 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Increment the tool-call counter; raise {Exceeded} once
|
|
43
|
+
# it crosses {#max}. Called by {Agent} from its
|
|
44
|
+
# +before_tool_call+ wiring.
|
|
45
|
+
#
|
|
46
|
+
# @return [void]
|
|
47
|
+
# @raise [Exceeded] when the counter has now exceeded
|
|
48
|
+
# {#max}
|
|
49
|
+
def tick!
|
|
50
|
+
@step += 1
|
|
51
|
+
raise Exceeded, @max if @step > @max
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Reset the counter back to zero. Called by {Agent} at the
|
|
55
|
+
# start of each turn (in {Agent#run_loop} before forwarding
|
|
56
|
+
# the user message to the chat) so the same instance can
|
|
57
|
+
# govern many turns across a long-running REPL. Mid-loop
|
|
58
|
+
# {Control::Interloper} injections deliberately do *not*
|
|
59
|
+
# trigger a reset — those are additional context for the
|
|
60
|
+
# same turn, not a fresh one, and a chatty user could
|
|
61
|
+
# otherwise refresh the budget forever by injecting.
|
|
62
|
+
#
|
|
63
|
+
# @return [void]
|
|
64
|
+
def reset!
|
|
65
|
+
@step = 0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Integer] current step count; exposed so callers
|
|
69
|
+
# can introspect it (and so tests can assert it)
|
|
70
|
+
attr_reader :step
|
|
71
|
+
|
|
72
|
+
# Sub-agent variant: a fresh +StepLimit+ at the
|
|
73
|
+
# caller-supplied +max_steps:+, or — when the key is
|
|
74
|
+
# absent — at the receiver's own cap. The mutable counter
|
|
75
|
+
# is per-chat, so the parent's instance cannot govern a
|
|
76
|
+
# sub-agent's chat; every sub-agent needs its own.
|
|
77
|
+
#
|
|
78
|
+
# @param max_steps [Integer] positive step cap for the
|
|
79
|
+
# sub-agent; defaults to the receiver's current cap
|
|
80
|
+
# @return [StepLimit]
|
|
81
|
+
# @raise [ArgumentError] if +max_steps+ is non-positive
|
|
82
|
+
def for_sub_agent(max_steps: @max)
|
|
83
|
+
self.class.new(max: max_steps)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [String] short config dump for {Agent#to_s}
|
|
87
|
+
def to_s
|
|
88
|
+
"StepLimit(max=#{@max})"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
class Agent
|
|
5
|
+
# Namespace for the +Agent+'s host-facing controls:
|
|
6
|
+
# {StepLimit}, {Cancellable}, and {Interloper}. Each is a small
|
|
7
|
+
# value-holder that the +Agent+ reads from (or pokes into the
|
|
8
|
+
# event stream from) at well-defined points in ruby_llm's
|
|
9
|
+
# chat-callback cycle. They are *not* listeners — they receive
|
|
10
|
+
# no events and never appear in a {ListenerList}.
|
|
11
|
+
#
|
|
12
|
+
# == Why they're separated
|
|
13
|
+
#
|
|
14
|
+
# Listeners are pure consumers of the event stream; controls
|
|
15
|
+
# are host-facing signal holders that the +Agent+ reads from.
|
|
16
|
+
# The +Agent+ is the only entity that emits events, the only
|
|
17
|
+
# entity that ticks the step counter, the only entity that
|
|
18
|
+
# checks the cancellation flag, and the only entity that drains
|
|
19
|
+
# the interloper queue. "What fires when" is a single grep for
|
|
20
|
+
# +@listeners.emit+ in +agent.rb+.
|
|
21
|
+
#
|
|
22
|
+
# == What each control does
|
|
23
|
+
#
|
|
24
|
+
# * {StepLimit} — caps the number of tool calls per
|
|
25
|
+
# {Agent#run_loop}. The +Agent+ calls {StepLimit#tick!} on
|
|
26
|
+
# every +before_tool_call+ (raising {StepLimit::Exceeded}
|
|
27
|
+
# when over budget) and {StepLimit#reset!} at the start of
|
|
28
|
+
# each turn. Sub-agents get their own counter.
|
|
29
|
+
# * {Cancellable} — cooperative cancellation flag. The host
|
|
30
|
+
# calls {Cancellable#cancel!} from any thread; the +Agent+
|
|
31
|
+
# calls {Cancellable#check!} at +before_tool_call+ (raising
|
|
32
|
+
# {Cancellable::Cancelled} when the flag is set) and
|
|
33
|
+
# {Cancellable#reset!} at the start of each turn. The same
|
|
34
|
+
# instance is shared by reference across the parent, every
|
|
35
|
+
# sub-agent, and the synthesizer rescue.
|
|
36
|
+
# * {Interloper} — mid-loop user-input queue. The host calls
|
|
37
|
+
# {Interloper#inject_user_message} from any thread; the
|
|
38
|
+
# +Agent+ drains the queue at +after_tool_result+,
|
|
39
|
+
# appending each item as a user-role message and emitting
|
|
40
|
+
# {Event::UserTurn} with +mid_loop: true+. Not propagated to
|
|
41
|
+
# sub-agents (the host has no handle to them).
|
|
42
|
+
module Control
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
class Agent
|
|
5
|
+
# Sealed value-object hierarchy describing a single event in the
|
|
6
|
+
# +Agent+'s normalized stream. Every listener consumes these through
|
|
7
|
+
# one {Listener::Base#on_event} entry point and pattern-matches on
|
|
8
|
+
# the variant.
|
|
9
|
+
#
|
|
10
|
+
# Each variant is a +Data.define+ with the minimal fields it needs;
|
|
11
|
+
# value equality and pattern-matching support come for free.
|
|
12
|
+
#
|
|
13
|
+
# == One stream, no side channels
|
|
14
|
+
#
|
|
15
|
+
# Provider-reported token usage rides as {Tokens}; the detected
|
|
16
|
+
# context-window cap rides as a one-shot {ContextCap} emitted by
|
|
17
|
+
# {Agent#initialize}; everything else maps to a turn-or-tool-call
|
|
18
|
+
# variant. Listeners override a single +on_event+ method and
|
|
19
|
+
# +case+-match on the variant they care about. The per-variant
|
|
20
|
+
# docs below name the emission site for each (which {Agent}
|
|
21
|
+
# callback wires it and what payload it carries).
|
|
22
|
+
module Event
|
|
23
|
+
# User's input for a turn (+mid_loop: false+, the default) or a
|
|
24
|
+
# host-supplied injection delivered while the loop is running
|
|
25
|
+
# (+mid_loop: true+, drained from {Control::Interloper}). The
|
|
26
|
+
# flag exists so listeners that treat the +UserTurn+ as a turn
|
|
27
|
+
# boundary can distinguish a fresh turn from an in-loop
|
|
28
|
+
# injection (additional context for the same turn, not a new
|
|
29
|
+
# one). Controls themselves no longer see this event — the
|
|
30
|
+
# +Agent+ pokes their +reset!+ / +tick!+ entry points directly
|
|
31
|
+
# at the right boundaries.
|
|
32
|
+
#
|
|
33
|
+
# Emitted in two places: by {Agent#run_loop} at the start of
|
|
34
|
+
# each turn (with +mid_loop: false+), and by {Agent}'s
|
|
35
|
+
# +after_tool_result+ wiring when a queued
|
|
36
|
+
# {Control::Interloper} item drains into the chat history
|
|
37
|
+
# (with +mid_loop: true+).
|
|
38
|
+
UserTurn = Data.define(:content, :mid_loop) do
|
|
39
|
+
# @param content [String] user-supplied text
|
|
40
|
+
# @param mid_loop [Boolean] +false+ for a turn-starting message
|
|
41
|
+
# (the default); +true+ when drained from
|
|
42
|
+
# {Control::Interloper}
|
|
43
|
+
def initialize(content:, mid_loop: false)
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Assistant reasoning ("thinking") block, extracted from the
|
|
49
|
+
# +thinking.text+ field on a +RubyLLM::Message+ with role
|
|
50
|
+
# +:assistant+. Emitted by {Agent}'s +after_message+ wiring;
|
|
51
|
+
# empty +thinking.text+ is filtered at the dispatch site so
|
|
52
|
+
# listeners never see vacuous events.
|
|
53
|
+
Thinking = Data.define(:content)
|
|
54
|
+
|
|
55
|
+
# Assistant Markdown content, extracted from a +RubyLLM::Message+
|
|
56
|
+
# with role +:assistant+. Emitted by {Agent}'s +after_message+
|
|
57
|
+
# wiring; empty +content+ is filtered at the dispatch site
|
|
58
|
+
# (pure tool-call turns surface {Tokens} only, no +Assistant+).
|
|
59
|
+
Assistant = Data.define(:content)
|
|
60
|
+
|
|
61
|
+
# Streaming fragment of an assistant reasoning block, pulled
|
|
62
|
+
# off a +RubyLLM::Chunk+ during a +Chat#ask+ stream. Emitted
|
|
63
|
+
# by the per-chunk streaming block {Agent.streaming_block}
|
|
64
|
+
# builds and {Agent#run_loop} / {Synthesizer.run} pass to
|
|
65
|
+
# +ask+; empty fragments are filtered at the dispatch site.
|
|
66
|
+
#
|
|
67
|
+
# Preview-only, not authoritative: the {Thinking} event
|
|
68
|
+
# emitted from +after_message+ at the end of the round-trip
|
|
69
|
+
# is the final reasoning text. Providers may normalize
|
|
70
|
+
# whitespace, and Anthropic thinking blocks include a
|
|
71
|
+
# signature that never appears in deltas, so
|
|
72
|
+
# +concat(deltas) == final.content+ is not guaranteed.
|
|
73
|
+
#
|
|
74
|
+
# == Ordering
|
|
75
|
+
#
|
|
76
|
+
# Per round-trip: all {ThinkingDelta}s (and {AssistantDelta}s)
|
|
77
|
+
# for a round arrive before that round's {Thinking} /
|
|
78
|
+
# {Assistant} / {Tokens} bookend, because the streaming block
|
|
79
|
+
# fires synchronously inside +Chat#ask+'s SSE read and
|
|
80
|
+
# +after_message+ fires once the message is complete. Within
|
|
81
|
+
# the delta stream itself, ordering between {ThinkingDelta}
|
|
82
|
+
# and {AssistantDelta} is provider-dependent (in practice
|
|
83
|
+
# non-interleaved on Anthropic and OpenAI reasoning models,
|
|
84
|
+
# but pikuri does not enforce it).
|
|
85
|
+
ThinkingDelta = Data.define(:content)
|
|
86
|
+
|
|
87
|
+
# Streaming fragment of an assistant Markdown content block,
|
|
88
|
+
# pulled off a +RubyLLM::Chunk+ during a +Chat#ask+ stream.
|
|
89
|
+
# Emitted by the per-chunk streaming block
|
|
90
|
+
# {Agent.streaming_block} builds and {Agent#run_loop} /
|
|
91
|
+
# {Synthesizer.run} pass to +ask+; empty fragments are
|
|
92
|
+
# filtered at the dispatch site.
|
|
93
|
+
#
|
|
94
|
+
# Preview-only, same semantics as {ThinkingDelta}: the
|
|
95
|
+
# {Assistant} event emitted from +after_message+ at the end
|
|
96
|
+
# of the round-trip is the authoritative final text;
|
|
97
|
+
# listeners that need an exact concat of fragments should
|
|
98
|
+
# consume {Assistant} instead. Per-round-trip ordering is
|
|
99
|
+
# guaranteed; per-modality ordering within the delta stream
|
|
100
|
+
# is best-effort.
|
|
101
|
+
AssistantDelta = Data.define(:content)
|
|
102
|
+
|
|
103
|
+
# A tool invocation the LLM has requested but not yet observed.
|
|
104
|
+
# Arguments are the raw hash ruby_llm parsed from the model's
|
|
105
|
+
# +tool_calls+ JSON — no validation has run yet. Emitted by
|
|
106
|
+
# {Agent}'s +before_tool_call+ wiring.
|
|
107
|
+
ToolCall = Data.define(:name, :arguments)
|
|
108
|
+
|
|
109
|
+
# The observation a tool produced, as returned by {Tool#run}.
|
|
110
|
+
# Recoverable failures arrive here as +"Error: ..."+ strings
|
|
111
|
+
# (per the pikuri error convention), not as exceptions.
|
|
112
|
+
# Emitted by {Agent}'s +after_tool_result+ wiring.
|
|
113
|
+
ToolResult = Data.define(:content)
|
|
114
|
+
|
|
115
|
+
# Provider-reported token usage for a single assistant turn,
|
|
116
|
+
# copied off a +RubyLLM::Message+'s +tokens+ block. Emitted by
|
|
117
|
+
# {Agent}'s +after_message+ wiring on every assistant turn,
|
|
118
|
+
# including pure tool-call turns where {Assistant} would have
|
|
119
|
+
# been filtered for empty content (those are exactly the turns
|
|
120
|
+
# where context-window growth matters most).
|
|
121
|
+
#
|
|
122
|
+
# All counts are +Integer, nil+. +nil+ means the provider did not
|
|
123
|
+
# report that field — common with local llama.cpp / Ollama
|
|
124
|
+
# servers that leave parts of the OpenAI +usage+ block empty.
|
|
125
|
+
# Listeners treat +nil+ as zero.
|
|
126
|
+
#
|
|
127
|
+
# The fields +input+, +cached+, and +cache_creation+ are
|
|
128
|
+
# **exclusive portions of this turn's full prompt** under the
|
|
129
|
+
# shape ruby_llm exposes for llama.cpp and Anthropic: they sum
|
|
130
|
+
# to the total prompt size processed on this request. OpenAI
|
|
131
|
+
# proper nests +cached_tokens+ inside its +prompt_tokens+
|
|
132
|
+
# instead — if pikuri ever talks there directly, the sum formula
|
|
133
|
+
# needs revisiting.
|
|
134
|
+
#
|
|
135
|
+
# - +input+ — newly-processed (uncached) prompt tokens this turn.
|
|
136
|
+
# - +output+ — tokens in this single assistant reply.
|
|
137
|
+
# - +cached+ — portion of this turn's prompt served from the
|
|
138
|
+
# provider's prompt cache. Still counts against the context
|
|
139
|
+
# window (caching is a speed/cost optimization, not a context-
|
|
140
|
+
# savings mechanism).
|
|
141
|
+
# - +cache_creation+ — portion of this turn's prompt written
|
|
142
|
+
# into the prompt cache. Anthropic-specific; usually +nil+ on
|
|
143
|
+
# OpenAI-compatible local servers.
|
|
144
|
+
# - +thinking+ — extended-thinking (Anthropic) or reasoning
|
|
145
|
+
# (OpenAI o-series) tokens produced on this turn. +nil+ on
|
|
146
|
+
# providers without a reasoning channel.
|
|
147
|
+
# - +model_id+ — provider-side model name as reported on the
|
|
148
|
+
# response; useful when a process targets multiple models.
|
|
149
|
+
#
|
|
150
|
+
# == Computing "current context window size"
|
|
151
|
+
#
|
|
152
|
+
# +input + cached + cache_creation+ is the size of the prompt
|
|
153
|
+
# processed on this turn. Add +output+ to get tokens consumed by
|
|
154
|
+
# the conversation *through* this turn — this turn's prompt plus
|
|
155
|
+
# its reply, both of which the model will re-process on the next
|
|
156
|
+
# turn. That's what climbs toward
|
|
157
|
+
# +RubyLLM::ContextLengthExceededError+ and is the snapshot
|
|
158
|
+
# {Listener::TokenLog#context_window_size} tracks.
|
|
159
|
+
Tokens = Data.define(:input, :output, :cached, :cache_creation, :thinking, :model_id)
|
|
160
|
+
|
|
161
|
+
# Model's resolved context-window cap. Emitted once by
|
|
162
|
+
# {Agent#initialize} immediately after
|
|
163
|
+
# {Agent::ContextWindowDetector} runs. Carries +nil+ when no
|
|
164
|
+
# source produced a value (custom local model with no override
|
|
165
|
+
# and no reachable llama.cpp +/props+). Listeners that care —
|
|
166
|
+
# {Listener::TokenLog} renders +ctx=<used>/<cap>+ when set,
|
|
167
|
+
# +ctx=<used>+ when +nil+ — pick the value off this event and
|
|
168
|
+
# cache it; non-caring listeners ignore.
|
|
169
|
+
ContextCap = Data.define(:cap)
|
|
170
|
+
|
|
171
|
+
# Out-of-band notice that the agent had to take a rescue path.
|
|
172
|
+
# Emitted by {Agent#run_loop} when {Control::StepLimit} trips
|
|
173
|
+
# and the synthesizer fallback runs; carries the reason string
|
|
174
|
+
# the listener should surface. Lets listeners (Terminal, future
|
|
175
|
+
# web UI) surface the divergence to the user before the
|
|
176
|
+
# synthesizer's own assistant output flows through.
|
|
177
|
+
FallbackNotice = Data.define(:reason)
|
|
178
|
+
|
|
179
|
+
# Out-of-band notice that the user cancelled the in-flight turn
|
|
180
|
+
# via {Control::Cancellable}. Emitted by {Agent#run_loop} just
|
|
181
|
+
# before the +Cancellable::Cancelled+ exception re-raises out of
|
|
182
|
+
# the loop, so listeners (Terminal renderer, structured
|
|
183
|
+
# recorders) can mark the turn as user-aborted. Unlike
|
|
184
|
+
# {FallbackNotice}, no recovery follows — the exception is
|
|
185
|
+
# re-raised and the caller is expected to return control to the
|
|
186
|
+
# user (typically the REPL prompt).
|
|
187
|
+
Cancelled = Data.define
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
class Agent
|
|
5
|
+
# The Extension protocol — how hosts bolt extra capabilities
|
|
6
|
+
# (system-prompt snippets, tools, lifecycle hooks) onto an
|
|
7
|
+
# {Agent}. Extensions are added via {Configurator#add_extension}
|
|
8
|
+
# inside the +Agent.new+ block; the Agent then drives two hooks
|
|
9
|
+
# on each — {#configure} during the block, {#bind} once the
|
|
10
|
+
# agent is fully constructed.
|
|
11
|
+
#
|
|
12
|
+
# Mix this module into an extension class to inherit empty
|
|
13
|
+
# default implementations of both hooks; override the ones you
|
|
14
|
+
# need. Extensions that don't +include+ this module still work
|
|
15
|
+
# if they define both methods themselves (the Agent and
|
|
16
|
+
# Configurator call them by name) — the module exists to make
|
|
17
|
+
# the protocol *explicit* and to give "I want to implement just
|
|
18
|
+
# +configure+" extensions a free no-op +bind+ (and vice versa).
|
|
19
|
+
#
|
|
20
|
+
# == Example
|
|
21
|
+
#
|
|
22
|
+
# class MyExtension
|
|
23
|
+
# include Pikuri::Agent::Extension
|
|
24
|
+
#
|
|
25
|
+
# def configure(c)
|
|
26
|
+
# c.append_system_prompt("Always be polite.")
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# # bind not overridden — inherits the empty default
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# See +Pikuri::Mcp::Extension+ and +Pikuri::Skill::Extension+
|
|
33
|
+
# (once those land in Steps 2-3 of the gem-split refactor — see
|
|
34
|
+
# IDEAS.md §"Extension protocol design") for the canonical
|
|
35
|
+
# worked implementations.
|
|
36
|
+
module Extension
|
|
37
|
+
# Called immediately by {Configurator#add_extension} during the
|
|
38
|
+
# +Agent.new+ block, with the parent agent's {Configurator}.
|
|
39
|
+
# Runs exactly once per extension instance, on the parent agent
|
|
40
|
+
# only — sub-agents do not re-run +configure+. The default is a
|
|
41
|
+
# no-op; override when you need to install *agent-agnostic*
|
|
42
|
+
# state. Things you typically do here:
|
|
43
|
+
#
|
|
44
|
+
# * append snippets to the system prompt via
|
|
45
|
+
# {Configurator#append_system_prompt}
|
|
46
|
+
# * register tools via {Configurator#add_tool}
|
|
47
|
+
# * register listeners via {Configurator#add_listener}
|
|
48
|
+
# * register parent-only +on_close+ handlers via
|
|
49
|
+
# {Configurator#on_close} (for cleanup of resources the
|
|
50
|
+
# extension created in +configure+)
|
|
51
|
+
# * read the agent's transport / cancellable / etc. via the
|
|
52
|
+
# Configurator's +attr_reader+s
|
|
53
|
+
#
|
|
54
|
+
# @param c [Configurator] the parent agent's Configurator
|
|
55
|
+
# @return [void]
|
|
56
|
+
def configure(c); end
|
|
57
|
+
|
|
58
|
+
# Called by {Agent#initialize} after the block returns and the
|
|
59
|
+
# chat is fully wired, with the live {Agent} as the argument.
|
|
60
|
+
# Runs once per agent — on the parent during its construction,
|
|
61
|
+
# and once more on each sub-agent during the sub-agent's
|
|
62
|
+
# construction (same extension instance, multiple +bind+ calls
|
|
63
|
+
# — per-agent state lives in +bind+'s closures, not in
|
|
64
|
+
# extension instance state). The default is a no-op; override
|
|
65
|
+
# when you need to install *per-agent* state. Things you
|
|
66
|
+
# typically do here:
|
|
67
|
+
#
|
|
68
|
+
# * register per-agent dynamic tools via
|
|
69
|
+
# {Agent#internal_add_tool}
|
|
70
|
+
# * register per-agent +on_close+ handlers via
|
|
71
|
+
# {Agent#on_close}
|
|
72
|
+
# * stash an +@agent+ reference if the extension's tools need
|
|
73
|
+
# to act on this specific agent later (e.g. when a tool
|
|
74
|
+
# fires and wants to register more tools on its owning
|
|
75
|
+
# chat)
|
|
76
|
+
#
|
|
77
|
+
# @param agent [Agent] the live agent, fully wired
|
|
78
|
+
# @return [void]
|
|
79
|
+
def bind(agent); end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
class Agent
|
|
5
|
+
module Listener
|
|
6
|
+
# Recording listener that appends every {Event} the agent
|
|
7
|
+
# emits to an in-memory list. Used by specs to assert on
|
|
8
|
+
# emissions without parsing stdout, and as the rough shape a
|
|
9
|
+
# future structured consumer (web sink, telemetry pipe) would
|
|
10
|
+
# take.
|
|
11
|
+
class InMemoryEventList < Base
|
|
12
|
+
# @return [Array<Agent::Event>] every event the listener
|
|
13
|
+
# has seen, in order; never nil
|
|
14
|
+
attr_reader :events
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
super
|
|
18
|
+
@events = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param event [Agent::Event]
|
|
22
|
+
# @return [void]
|
|
23
|
+
def on_event(event)
|
|
24
|
+
@events << event
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [String] short label for {Agent#to_s}
|
|
28
|
+
def to_s
|
|
29
|
+
'InMemoryEventList'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|