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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +67 -0
  3. data/lib/pikuri/agent/chat_transport.rb +41 -0
  4. data/lib/pikuri/agent/configurator.rb +270 -0
  5. data/lib/pikuri/agent/context_window_detector.rb +111 -0
  6. data/lib/pikuri/agent/control/cancellable.rb +128 -0
  7. data/lib/pikuri/agent/control/interloper.rb +167 -0
  8. data/lib/pikuri/agent/control/step_limit.rb +93 -0
  9. data/lib/pikuri/agent/control.rb +45 -0
  10. data/lib/pikuri/agent/event.rb +190 -0
  11. data/lib/pikuri/agent/extension.rb +82 -0
  12. data/lib/pikuri/agent/listener/in_memory_event_list.rb +34 -0
  13. data/lib/pikuri/agent/listener/rate_limited.rb +172 -0
  14. data/lib/pikuri/agent/listener/terminal.rb +264 -0
  15. data/lib/pikuri/agent/listener/token_log.rb +216 -0
  16. data/lib/pikuri/agent/listener.rb +54 -0
  17. data/lib/pikuri/agent/listener_list.rb +102 -0
  18. data/lib/pikuri/agent/synthesizer.rb +145 -0
  19. data/lib/pikuri/agent.rb +731 -0
  20. data/lib/pikuri/subprocess.rb +166 -0
  21. data/lib/pikuri/tool/calculator.rb +82 -0
  22. data/lib/pikuri/tool/fetch.rb +171 -0
  23. data/lib/pikuri/tool/parameters.rb +314 -0
  24. data/lib/pikuri/tool/scraper/fetch_error.rb +16 -0
  25. data/lib/pikuri/tool/scraper/html.rb +285 -0
  26. data/lib/pikuri/tool/scraper/pdf.rb +54 -0
  27. data/lib/pikuri/tool/scraper/simple.rb +183 -0
  28. data/lib/pikuri/tool/search/brave.rb +184 -0
  29. data/lib/pikuri/tool/search/duckduckgo.rb +196 -0
  30. data/lib/pikuri/tool/search/engines.rb +163 -0
  31. data/lib/pikuri/tool/search/exa.rb +217 -0
  32. data/lib/pikuri/tool/search/rate_limiter.rb +92 -0
  33. data/lib/pikuri/tool/search/result.rb +29 -0
  34. data/lib/pikuri/tool/sub_agent.rb +150 -0
  35. data/lib/pikuri/tool/web_scrape.rb +121 -0
  36. data/lib/pikuri/tool/web_search.rb +38 -0
  37. data/lib/pikuri/tool.rb +118 -0
  38. data/lib/pikuri/url_cache.rb +112 -0
  39. data/lib/pikuri/version.rb +10 -0
  40. data/lib/pikuri-core.rb +177 -0
  41. data/prompts/pikuri-chat.txt +15 -0
  42. 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