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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9d64fe3a6b311841de49b193f6caa92cdb2ba0e627ef0195c73824d803fcafa
4
- data.tar.gz: fa1112813faf7c4d6d162d467b4977c7b800bc647d35c0fe6fec79a1f30e6112
3
+ metadata.gz: 914f6e02052e97773e630e32bc9689bf7edc0eee4e962419aa41a15ca4afa667
4
+ data.tar.gz: e9a68c3813a0bbcd292757ee2cb4dbce6df7abc890e0d4afa6cf51cae77a6247
5
5
  SHA512:
6
- metadata.gz: f9b5618853d0501a417fbcfd021e1dd65fe45fef42331d9f8b4f1c8ab273a91437f433b7065512b46340d98670e53127cb49a5149eb7078750bd99a0e6ad22fa
7
- data.tar.gz: a9ce6040b23bcd4a2eb4f11bc10c50cd64ad8fdba685e2eba51f9181a3f3503617adb230b0601257e3f0ec3122c14d1a12b5091ab03b7612f906d72c54938189
6
+ metadata.gz: 3f0bdf7af9a4a85d3669b01f0929b92fdb2acbc9c7cb083611c4acaecc4d3b5b37a3e3d97a1fd060bec143016f4db00968707d149f127eb1dea5a7243dd51a73
7
+ data.tar.gz: cf92ad370fed6574f9cd7cc44fbd93e36c6bb03c6d56555267dc0a22a723ca9ce298231495b902570bfbbdde6b5e6b319860b2b625818eb2bdbb51d5dd2346e2
data/README.md CHANGED
@@ -65,3 +65,13 @@ agent.run_loop(user_message: 'What is 17 * 23?')
65
65
 
66
66
  See `bin/pikuri-chat` for a worked example with REPL, signal
67
67
  handling, and cancellation.
68
+
69
+ ## Further reading
70
+
71
+ - **Narrative walkthrough:** [chapter 1 of the pikuri guide](../docs/guide/01-chat.md)
72
+ — install llama.cpp, start the server, run `pikuri-chat`, the
73
+ agentic loop, the four bundled tools, and search-provider
74
+ privacy postures.
75
+ - **API reference:** browse the YARD docs at
76
+ <https://rubydoc.info/gems/pikuri-core> (once published), or run
77
+ `bundle exec yard` in this directory for a local copy.
@@ -8,11 +8,12 @@ module Pikuri
8
8
  #
9
9
  # Bundling them is structural protection against a recurring bug
10
10
  # class — every forwarding site (the synthesizer rescue in
11
- # {Agent#run_loop}, {Tool::SubAgent} spawning a sub-agent) used to
12
- # pass the three individually, and dropping one routed the spawned
13
- # chat to a different server or raised +RubyLLM::ModelNotFoundError+
14
- # on the unknown model id. With a single value object the call site
15
- # can't silently miss a field.
11
+ # {Agent#run_loop}, the +agent+ tool from +pikuri-subagents+
12
+ # spawning a sub-agent) used to pass the three individually, and
13
+ # dropping one routed the spawned chat to a different server or
14
+ # raised +RubyLLM::ModelNotFoundError+ on the unknown model id.
15
+ # With a single value object the call site can't silently miss a
16
+ # field.
16
17
  #
17
18
  # Pure data carrier: no +RubyLLM+ references here, so the seam stays
18
19
  # in {Agent}, +bin/pikuri-chat+, and {Tool}.
@@ -5,8 +5,9 @@ module Pikuri
5
5
  # Build-time collector yielded into the +Pikuri::Agent.new+ block.
6
6
  # Hosts and {Extension} implementations call its methods to declare
7
7
  # additional tools, listeners, system-prompt snippets, +on_close+
8
- # handlers, and extension instances; {Agent#initialize} drains the
9
- # collected state into the agent's final wiring before returning.
8
+ # handlers, extension instances, and persona-flavored sub-agents;
9
+ # {Agent#initialize} drains the collected state into the agent's
10
+ # final wiring before returning.
10
11
  #
11
12
  # == Why this exists
12
13
  #
@@ -15,10 +16,27 @@ module Pikuri
15
16
  #
16
17
  # Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
17
18
  # c.add_listener Pikuri::Agent::Listener::Terminal.new
18
- # c.add_tool Pikuri::Tool::CALCULATOR
19
+ # c.add_tool Pikuri::Tool::WEB_SEARCH
20
+ # c.add_tool Pikuri::Tool::WEB_SCRAPE
21
+ # c.add_tool Pikuri::Tool::FETCH
19
22
  # c.add_extension Pikuri::Skill::Extension.new(catalog: catalog)
20
23
  # end
21
24
  #
25
+ # == Two tool pools: regular vs. sub-agent-only
26
+ #
27
+ # {#add_tool} registers a tool the parent agent can call: it lands
28
+ # in {#tools} and gets handed to ruby_llm via +chat.with_tool+.
29
+ # {#add_sub_agent_tool} registers a tool the parent *cannot* call
30
+ # — it lands in {#sub_agent_tools} and is never sent to ruby_llm
31
+ # for the parent, but is visible to {Pikuri::SubAgent::Extension}'s
32
+ # persona-tool-name resolution. The use case is the lethal-trifecta
33
+ # defense in {Pikuri::Code::Bash::Sandbox} terms: keep network tools
34
+ # (+web_search+ / +web_scrape+ / +fetch+) off the parent so a prompt-
35
+ # injected file read cannot egress through the parent's own tools,
36
+ # while still letting the +researcher+ persona reach them via the
37
+ # +agent+ delegation tool. See SECURITY.md §"Defense: capability
38
+ # boundaries via sub-agents".
39
+ #
22
40
  # Extensions implement their +configure(c)+ hook against the same
23
41
  # type, so the call sites for "block users add stuff" and
24
42
  # "extensions add stuff" share one API.
@@ -44,9 +62,9 @@ module Pikuri
44
62
  # available for diagnostics.
45
63
  attr_reader :system_prompt_base
46
64
 
47
- # @return [String] this agent's identifier; empty for the main
48
- # agent, hierarchical (+"sub_agent 0_1"+) for sub-agents.
49
- attr_reader :name
65
+ # @return [String] this agent's unique identifier; empty for the
66
+ # main agent, persona-rooted (e.g. +"researcher 0"+) for sub-agents.
67
+ attr_reader :id
50
68
 
51
69
  # @return [Boolean] +true+ when the agent opted into chunk-level
52
70
  # streaming.
@@ -68,9 +86,18 @@ module Pikuri
68
86
  attr_reader :interloper
69
87
 
70
88
  # @return [Array<Tool>] tools added via {#add_tool}, in
71
- # declaration order. Drained by {Agent#initialize}.
89
+ # declaration order. Drained by {Agent#initialize} and
90
+ # registered with ruby_llm so the parent LLM can call them.
72
91
  attr_reader :tools
73
92
 
93
+ # @return [Array<Tool>] tools added via {#add_sub_agent_tool},
94
+ # in declaration order. Drained by {Agent#initialize} but
95
+ # *not* registered with ruby_llm — invisible to the parent
96
+ # LLM, available only to sub-agents through
97
+ # {Pikuri::SubAgent::Extension}'s persona-tool-name
98
+ # resolution. See the class header.
99
+ attr_reader :sub_agent_tools
100
+
74
101
  # @return [Array<Listener::Base>] listeners added via
75
102
  # {#add_listener}, in declaration order. Drained by
76
103
  # {Agent#initialize}.
@@ -93,41 +120,35 @@ module Pikuri
93
120
  # wiring is complete.
94
121
  attr_reader :extensions
95
122
 
96
- # @return [SubAgentRequest, nil] set when the block called
97
- # {#allow_sub_agent}; +nil+ otherwise. The Agent ctor uses
98
- # this to decide whether to create a {Tool::SubAgent}
99
- # instance after the chat is wired (so the SubAgent tool's
100
- # parent.tools snapshot doesn't include itself —
101
- # recursion guard).
102
- attr_reader :sub_agent_request
103
-
104
- # Record holding the +max_steps+ value the host passed to
105
- # {#allow_sub_agent}. The Agent ctor consumes one of these
106
- # records to build a {Tool::SubAgent} keyed to that step
107
- # budget.
108
- SubAgentRequest = Data.define(:max_steps)
109
-
110
123
  # @param transport [Agent::ChatTransport]
111
124
  # @param system_prompt_base [String]
112
- # @param name [String]
125
+ # @param id [String]
113
126
  # @param streaming [Boolean]
114
127
  # @param step_limit [Control::StepLimit, nil]
115
128
  # @param cancellable [Control::Cancellable, nil]
116
129
  # @param interloper [Control::Interloper, nil]
117
- def initialize(transport:, system_prompt_base:, name:, streaming:,
118
- step_limit:, cancellable:, interloper:)
130
+ # @param on_close_sink [Array<Proc>, nil] array that {#on_close}
131
+ # appends to. {Agent#initialize} passes its own live
132
+ # +@on_close_handlers+ so a handler an extension arms via
133
+ # +c.on_close+ is reachable the instant it's registered — which
134
+ # is what lets the constructor close a half-built agent if a
135
+ # later extension's +configure+ raises. Defaults to a fresh
136
+ # array for standalone use (e.g. specs).
137
+ def initialize(transport:, system_prompt_base:, id:, streaming:,
138
+ step_limit:, cancellable:, interloper:, on_close_sink: nil)
119
139
  @transport = transport
120
140
  @system_prompt_base = system_prompt_base
121
- @name = name
141
+ @id = id
122
142
  @streaming = streaming
123
143
  @step_limit = step_limit
124
144
  @cancellable = cancellable
125
145
  @interloper = interloper
126
146
 
127
147
  @tools = []
148
+ @sub_agent_tools = []
128
149
  @listeners = []
129
150
  @system_prompt_additions = []
130
- @on_close_handlers = []
151
+ @on_close_handlers = on_close_sink || []
131
152
  @extensions = []
132
153
  end
133
154
 
@@ -142,8 +163,6 @@ module Pikuri
142
163
 
143
164
  # Append several tools at once. Equivalent to calling {#add_tool}
144
165
  # for each element of +tools+; declaration order is preserved.
145
- # Sole intended caller today is {Tool::SubAgent}, which seeds a
146
- # sub-agent's Configurator from the parent's tool snapshot.
147
166
  #
148
167
  # @param tools [Enumerable<Tool>]
149
168
  # @return [void]
@@ -152,6 +171,18 @@ module Pikuri
152
171
  nil
153
172
  end
154
173
 
174
+ # Append a tool to the sub-agent-only pool. The parent LLM
175
+ # never sees it; only sub-agents whose persona +tool_names+
176
+ # include the tool's +name+ get it in their toolset. See the
177
+ # class header for the trifecta-defense rationale.
178
+ #
179
+ # @param tool [Tool]
180
+ # @return [void]
181
+ def add_sub_agent_tool(tool)
182
+ @sub_agent_tools << tool
183
+ nil
184
+ end
185
+
155
186
  # Append a listener to the agent's listener list.
156
187
  #
157
188
  # @param listener [Listener::Base]
@@ -163,9 +194,6 @@ module Pikuri
163
194
 
164
195
  # Append several listeners at once. Accepts any enumerable
165
196
  # (Array, {ListenerList}, …); declaration order is preserved.
166
- # Sole intended caller today is {Tool::SubAgent}, which seeds a
167
- # sub-agent's Configurator from the parent's listener list (run
168
- # through {ListenerList#for_sub_agent}).
169
197
  #
170
198
  # @param listeners [Enumerable<Listener::Base>]
171
199
  # @return [void]
@@ -207,26 +235,6 @@ module Pikuri
207
235
  nil
208
236
  end
209
237
 
210
- # Retain a list of already-configured extensions for the
211
- # bind sweep without re-running +configure+. Sole intended
212
- # caller is {Tool::SubAgent}, which seeds a sub-agent's
213
- # Configurator from the parent's extension list — the parent
214
- # already drove +configure+ and its system-prompt snippets /
215
- # static tools are inherited by the sub-agent verbatim
216
- # through {#add_tools} + {#add_listeners} + the augmented
217
- # +system_prompt+; re-running +configure+ on the sub-agent
218
- # would double up those contributions. The +bind(sub_agent)+
219
- # sweep in {Agent#initialize} still fires on each inherited
220
- # extension so per-agent state (MCP's per-agent connect tool,
221
- # for example) gets installed fresh on the sub-agent.
222
- #
223
- # @param extensions [Enumerable<Extension>]
224
- # @return [void]
225
- def inherit_extensions(extensions)
226
- extensions.each { |ext| @extensions << ext }
227
- nil
228
- end
229
-
230
238
  # Register a handler called by {Agent#close}. Handlers fire in
231
239
  # LIFO order, each inside its own +rescue+ — same semantics as
232
240
  # +ensure+-block cleanup discipline.
@@ -239,32 +247,6 @@ module Pikuri
239
247
  @on_close_handlers << blk
240
248
  nil
241
249
  end
242
-
243
- # Enable the +sub_agent+ tool on this agent. Records the
244
- # +max_steps+ budget for sub-agent runs; the Agent ctor reads
245
- # {#sub_agent_request} after the block returns and constructs
246
- # the actual {Tool::SubAgent} so its snapshot of the parent's
247
- # tool list doesn't include itself (recursion guard).
248
- #
249
- # Sub-agents inherit the parent's tool list (minus +sub_agent+
250
- # itself), augmented system prompt, listeners (via
251
- # +for_sub_agent+ per listener), controls (per the per-control
252
- # rule), and the parent's extension list. Each inherited
253
- # extension's +bind(sub_agent)+ fires during the sub-agent's
254
- # construction — that's how MCP's per-agent connect tool ends
255
- # up keyed to the sub-agent rather than the parent.
256
- #
257
- # @param max_steps [Integer] step budget for each sub-agent
258
- # run, passed to {Tool::SubAgent#initialize}.
259
- # @raise [RuntimeError] if called more than once on the same
260
- # Configurator.
261
- # @return [void]
262
- def allow_sub_agent(max_steps: 10)
263
- raise 'allow_sub_agent may only be called once per agent' if @sub_agent_request
264
-
265
- @sub_agent_request = SubAgentRequest.new(max_steps: max_steps)
266
- nil
267
- end
268
250
  end
269
251
  end
270
252
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'faraday'
4
4
  require 'json'
5
+ require 'cgi'
5
6
 
6
7
  module Pikuri
7
8
  class Agent
@@ -31,6 +32,20 @@ module Pikuri
31
32
  # (typically +bin/pikuri-chat+) derives the right URL from its configured
32
33
  # base.
33
34
  #
35
+ # == llama.cpp router mode
36
+ #
37
+ # A llama.cpp *router* (the multi-instance front that proxies to N
38
+ # on-demand model servers) answers a bare +/props+ with
39
+ # +{"role":"router", ..., "n_ctx":0}+ — there is no single loaded
40
+ # model at the router itself, so its top-level +n_ctx+ is +0+. The
41
+ # real per-model cap is one proxied hop away: +GET /props?model=<id>+
42
+ # routes the probe to that model's instance, whose +/props+ carries
43
+ # the launched +n_ctx+. So when the bare probe reports +role: router+
44
+ # and a +model_id+ is known, this re-probes with the model id before
45
+ # giving up. A plain single-model server is untouched: its bare
46
+ # +/props+ already carries a positive +n_ctx+, so the router branch
47
+ # never runs.
48
+ #
34
49
  # == Failure handling
35
50
  #
36
51
  # The probe is best-effort. HTTP error, timeout, non-JSON body, or a
@@ -64,10 +79,15 @@ module Pikuri
64
79
  # +RubyLLM::Chat#model.context_window+
65
80
  # @param llama_probe_url [String, nil] full URL to llama.cpp +/props+;
66
81
  # +nil+ or empty string skips the probe
67
- def initialize(override:, ruby_llm_reported:, llama_probe_url:)
82
+ # @param model_id [String, nil] the chat model id, used only to
83
+ # follow a llama.cpp router via +/props?model=<id>+ when the bare
84
+ # probe reports +role: router+. +nil+ or empty disables that
85
+ # second hop.
86
+ def initialize(override:, ruby_llm_reported:, llama_probe_url:, model_id: nil)
68
87
  @override = override
69
88
  @ruby_llm_reported = ruby_llm_reported
70
89
  @llama_probe_url = llama_probe_url
90
+ @model_id = model_id
71
91
  end
72
92
 
73
93
  # @return [Integer, nil] resolved cap, or +nil+ if no source produced
@@ -83,25 +103,65 @@ module Pikuri
83
103
  private
84
104
 
85
105
  def probe_llama_cpp
86
- response = Faraday.new(
87
- request: { open_timeout: OPEN_TIMEOUT, timeout: READ_TIMEOUT }
88
- ).get(@llama_probe_url) do |req|
89
- req.headers['Accept'] = 'application/json'
90
- end
106
+ data = fetch_props(@llama_probe_url)
107
+ return nil if data.nil?
91
108
 
92
- return warn_and_nil("HTTP #{response.status} from #{@llama_probe_url}") unless response.status == 200
109
+ n_ctx = positive_n_ctx(data)
110
+ return n_ctx if n_ctx
93
111
 
94
- data = JSON.parse(response.body)
95
- n_ctx = data.dig('default_generation_settings', 'n_ctx')
96
- return n_ctx if n_ctx.is_a?(Integer) && n_ctx.positive?
112
+ # llama.cpp router: the bare /props carries no model, so its
113
+ # n_ctx is 0. Follow the router to the model's own instance.
114
+ return probe_router_model if data['role'] == 'router' && model_id_present?
97
115
 
98
116
  warn_and_nil(
99
117
  "no positive integer at default_generation_settings.n_ctx in #{@llama_probe_url} response"
100
118
  )
119
+ end
120
+
121
+ def probe_router_model
122
+ url = "#{@llama_probe_url}?model=#{CGI.escape(@model_id)}"
123
+ data = fetch_props(url)
124
+ return nil if data.nil?
125
+
126
+ n_ctx = positive_n_ctx(data)
127
+ return n_ctx if n_ctx
128
+
129
+ warn_and_nil(
130
+ "no positive integer at default_generation_settings.n_ctx in router probe #{url}"
131
+ )
132
+ end
133
+
134
+ # GETs +url+ and parses its JSON body.
135
+ #
136
+ # @param url [String] a llama.cpp +/props+ URL
137
+ # @return [Hash, nil] the parsed body, or +nil+ (after one +warn+
138
+ # line) on non-200, timeout, transport error, or non-JSON body
139
+ def fetch_props(url)
140
+ response = Faraday.new(
141
+ request: { open_timeout: OPEN_TIMEOUT, timeout: READ_TIMEOUT }
142
+ ).get(url) do |req|
143
+ req.headers['Accept'] = 'application/json'
144
+ end
145
+
146
+ return warn_and_nil("HTTP #{response.status} from #{url}") unless response.status == 200
147
+
148
+ JSON.parse(response.body)
101
149
  rescue Faraday::Error, JSON::ParserError => e
102
150
  warn_and_nil("#{e.class.name.split('::').last}: #{e.message}")
103
151
  end
104
152
 
153
+ # @param data [Hash] a parsed +/props+ body
154
+ # @return [Integer, nil] the launched +n_ctx+ when present and
155
+ # positive, else +nil+
156
+ def positive_n_ctx(data)
157
+ n_ctx = data.dig('default_generation_settings', 'n_ctx')
158
+ n_ctx if n_ctx.is_a?(Integer) && n_ctx.positive?
159
+ end
160
+
161
+ def model_id_present?
162
+ !@model_id.nil? && !@model_id.empty?
163
+ end
164
+
105
165
  def warn_and_nil(reason)
106
166
  LOGGER.warn("llama.cpp /props probe failed: #{reason}")
107
167
  nil
@@ -40,13 +40,13 @@ module Pikuri
40
40
  #
41
41
  # == Sub-agent semantics
42
42
  #
43
- # {#for_sub_agent} returns +self+ the same instance is
44
- # shared by reference across the parent, every sub-agent,
45
- # and the synthesizer rescue. One {#cancel!} call stops the
46
- # whole tree. This contrasts with {StepLimit}, which gives
47
- # each agent its own counter (because step budgets are
48
- # per-agent concerns) cancellation is a global signal
49
- # from the user, so it must propagate down.
43
+ # Cancellation is a global "stop the whole tree" signal —
44
+ # the +agent+ tool from +pikuri-subagents+ shares the
45
+ # parent's +Cancellable+ by reference when spawning a child,
46
+ # so one {#cancel!} call stops the parent, every running
47
+ # sub-agent, and the synthesizer rescue. The sharing rule
48
+ # lives at the spawn site (sub-agent code), not on this
49
+ # class.
50
50
  class Cancellable
51
51
  # Raised by {#check!} once {#cancel!} has been called.
52
52
  # Carries no fields; the cancellation reason ("the user
@@ -105,16 +105,6 @@ module Pikuri
105
105
  @cancelled = false
106
106
  end
107
107
 
108
- # Sub-agent variant: the same instance, shared by
109
- # reference, so a single {#cancel!} stops the parent,
110
- # every running sub-agent, and the synthesizer rescue.
111
- # See the class header for the rationale.
112
- #
113
- # @return [Cancellable] the receiver
114
- def for_sub_agent(**)
115
- self
116
- end
117
-
118
108
  # @return [String] short label for {Agent#to_s}; reflects
119
109
  # the current flag state so a startup banner or debug
120
110
  # print can tell an armed token apart from one that has
@@ -70,16 +70,16 @@ module Pikuri
70
70
  #
71
71
  # == Sub-agent semantics
72
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.
73
+ # Sub-agents are private to the parent agent; the host has
74
+ # no handle to them, so a child +Interloper+ would be
75
+ # unreachable. The +agent+ tool from +pikuri-subagents+
76
+ # therefore omits the kwarg when spawning a child, leaving
77
+ # the sub-agent's +interloper+ at its default +nil+. The
78
+ # behavior contrasts with {Control::Cancellable}, which is
79
+ # shared by reference so the parent's signal propagates to
80
+ # children — cancellation is a global "stop the whole tree"
81
+ # event, whereas injection is a directed "talk to the main
82
+ # agent" event.
83
83
  class Interloper
84
84
  def initialize
85
85
  @mutex = Mutex.new
@@ -123,6 +123,14 @@ module Pikuri
123
123
  @mutex.synchronize { !@items.empty? }
124
124
  end
125
125
 
126
+ # @return [Integer] number of pending injections; like
127
+ # {#pending?} and {#peek}, a snapshot observable from
128
+ # any thread — by the time the caller reads it the
129
+ # queue may already have drained
130
+ def size
131
+ @mutex.synchronize { @items.size }
132
+ end
133
+
126
134
  # Atomically take and remove all pending items. Called by
127
135
  # {Agent}'s +after_tool_result+ wiring; the +Agent+ then
128
136
  # appends each item to the chat history and emits an
@@ -143,23 +151,12 @@ module Pikuri
143
151
  end
144
152
  end
145
153
 
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
154
  # @return [String] short label for {Agent#to_s}; reflects
158
155
  # the pending-count so a debug print or banner can tell
159
156
  # an idle interloper apart from one with queued items
160
157
  def to_s
161
- size = @mutex.synchronize { @items.size }
162
- size.zero? ? 'Interloper' : "Interloper(#{size} pending)"
158
+ pending = size
159
+ pending.zero? ? 'Interloper' : "Interloper(#{pending} pending)"
163
160
  end
164
161
  end
165
162
  end
@@ -69,20 +69,6 @@ module Pikuri
69
69
  # can introspect it (and so tests can assert it)
70
70
  attr_reader :step
71
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
72
  # @return [String] short config dump for {Agent#to_s}
87
73
  def to_s
88
74
  "StepLimit(max=#{@max})"
@@ -45,6 +45,21 @@ module Pikuri
45
45
  end
46
46
  end
47
47
 
48
+ # A system-role block an {Extension#on_user_message} hook
49
+ # injected into the chat log — recalled reference (memory
50
+ # context, retrieved snippets) tagged +role: :system+ so the
51
+ # model reads it as background, not new user input. Carries
52
+ # the injected text verbatim.
53
+ #
54
+ # Emitted by {Agent#dispatch_ext_on_user_message}, once per
55
+ # extension that returns a non-empty block, at the same site
56
+ # that grows the chat log — so the event stream stays a
57
+ # faithful mirror of what the model actually sees. Without it
58
+ # an injection is invisible: it never surfaces in the stream,
59
+ # only as a secondary echo in the assistant's later reasoning.
60
+ # {Listener::Terminal} renders it dim grey with a +⊕+ marker.
61
+ SystemInjected = Data.define(:content)
62
+
48
63
  # Assistant reasoning ("thinking") block, extracted from the
49
64
  # +thinking.text+ field on a +RubyLLM::Message+ with role
50
65
  # +:assistant+. Emitted by {Agent}'s +after_message+ wiring;
@@ -5,17 +5,20 @@ module Pikuri
5
5
  # The Extension protocol — how hosts bolt extra capabilities
6
6
  # (system-prompt snippets, tools, lifecycle hooks) onto an
7
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.
8
+ # inside the +Agent.new+ block; the Agent then drives three hooks
9
+ # on each — {#configure} during the block, {#bind} once the agent
10
+ # is fully constructed, and {#on_user_message} on every user turn
11
+ # thereafter.
11
12
  #
12
13
  # 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).
14
+ # default implementations of all three hooks; override the ones
15
+ # you need. Extensions that don't +include+ this module still
16
+ # work *if they define all three methods themselves* the Agent
17
+ # and Configurator call them by name with no +respond_to?+ guard,
18
+ # so a missing one raises. The module exists to make the protocol
19
+ # *explicit* and to give "I want to implement just +configure+"
20
+ # extensions free no-op +bind+ / +on_user_message+ defaults (and
21
+ # any other combination).
19
22
  #
20
23
  # == Example
21
24
  #
@@ -57,26 +60,49 @@ module Pikuri
57
60
 
58
61
  # Called by {Agent#initialize} after the block returns and the
59
62
  # 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:
63
+ # Fires once per agent the extension was registered to via
64
+ # {Configurator#add_extension} in the typical setup that's
65
+ # the parent agent only, since sub-agents do not inherit
66
+ # extensions. The default is a no-op; override when you need
67
+ # to install state keyed to the live agent object. Things
68
+ # you typically do here:
67
69
  #
68
- # * register per-agent dynamic tools via
69
- # {Agent#internal_add_tool}
70
- # * register per-agent +on_close+ handlers via
71
- # {Agent#on_close}
70
+ # * register dynamic tools via {Agent#internal_add_tool}
71
+ # (used by {Pikuri::Mcp::Extension} for +mcp_connect+,
72
+ # whose +execute+ closure needs the live agent so
73
+ # activations register on the right chat)
74
+ # * register +on_close+ handlers via {Agent#on_close}
72
75
  # * 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
+ # to act on this specific agent later
76
77
  #
77
78
  # @param agent [Agent] the live agent, fully wired
78
79
  # @return [void]
79
80
  def bind(agent); end
81
+
82
+ # Optional per-turn hook fired by the {Agent} after a user-message
83
+ # is added to the chat. The
84
+ # default is a no-op returning +nil+; override and return {String}
85
+ # to emit a `:system` message with that text.
86
+ #
87
+ # == Append-only, never mutate
88
+ #
89
+ # The Agent only ever *appends* the returned block at the tail; it never
90
+ # rewrites or removes an earlier one. Mutating mid-log would bust the
91
+ # provider prefix cache for every message after the edit. Stale blocks
92
+ # ride the existing context-window machinery, not a per-turn rewrite.
93
+ #
94
+ # == Not inherited by sub-agents
95
+ #
96
+ # Like the rest of the extension surface, this fires on the parent agent
97
+ # only — sub-agents do not inherit extensions, so a persona's turns are
98
+ # never prefetched or recorded by the parent's memory.
99
+ #
100
+ # @param agent [Agent] the live agent whose turn this is
101
+ # @param content [String] the user message (initial or interloper) about
102
+ # to be sent to the model
103
+ # @return [String, nil] an optional block of text to be injected verbatim as
104
+ # a system-role message (after the user message), or +nil+ to inject nothing
105
+ def on_user_message(agent, content); end
80
106
  end
81
107
  end
82
108
  end
@@ -9,7 +9,9 @@ module Pikuri
9
9
  # Terminal renderer for the normalized event stream: dim grey
10
10
  # reasoning, Markdown-rendered assistant content, cyan tool-
11
11
  # call and tool-result lines, yellow fallback notice, red
12
- # cancelled notice. {Event::UserTurn} is intentionally silent
12
+ # cancelled notice. An {Event::SystemInjected} block (recalled
13
+ # memory / context an extension injected) renders dim grey
14
+ # with a +⊕+ marker. {Event::UserTurn} is intentionally silent
13
15
  # (the terminal user just typed the message, so re-rendering
14
16
  # it adds nothing); {Event::Tokens} and {Event::ContextCap}
15
17
  # are silent too (their consumer is {TokenLog}).
@@ -123,6 +125,8 @@ module Pikuri
123
125
  stream_fragment(Rainbow(content).color(85, 85, 85)) if @streaming
124
126
  in Event::AssistantDelta(content:)
125
127
  stream_fragment(content) if @streaming
128
+ in Event::SystemInjected(content:)
129
+ println(indent(Rainbow("⊕ #{content}").color(85, 85, 85)))
126
130
  in Event::ToolCall(name:, arguments:)
127
131
  args = arguments.map { |k, v| "#{k}=#{v.inspect}" }.join(', ')
128
132
  println(indent(Rainbow("→ #{name}(#{args})").cyan))