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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 914f6e02052e97773e630e32bc9689bf7edc0eee4e962419aa41a15ca4afa667
|
|
4
|
+
data.tar.gz: e9a68c3813a0bbcd292757ee2cb4dbce6df7abc890e0d4afa6cf51cae77a6247
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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},
|
|
12
|
-
# pass the three individually, and
|
|
13
|
-
# chat to a different server or
|
|
14
|
-
# on the unknown model id.
|
|
15
|
-
# can't silently miss a
|
|
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,
|
|
9
|
-
# collected state into the agent's
|
|
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::
|
|
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
|
|
48
|
-
# agent,
|
|
49
|
-
attr_reader :
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
109
|
+
n_ctx = positive_n_ctx(data)
|
|
110
|
+
return n_ctx if n_ctx
|
|
93
111
|
|
|
94
|
-
|
|
95
|
-
n_ctx
|
|
96
|
-
return
|
|
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
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
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
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
# contrasts with {Control::Cancellable}, which
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
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
|
-
|
|
162
|
-
|
|
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})"
|
data/lib/pikuri/agent/event.rb
CHANGED
|
@@ -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
|
|
9
|
-
# on each — {#configure} during the block, {#bind} once the
|
|
10
|
-
#
|
|
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
|
|
14
|
-
# need. Extensions that don't +include+ this module still
|
|
15
|
-
# if they define
|
|
16
|
-
# Configurator call them by name
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
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
|
|
69
|
-
# {
|
|
70
|
-
#
|
|
71
|
-
#
|
|
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
|
|
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::
|
|
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))
|