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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b9d64fe3a6b311841de49b193f6caa92cdb2ba0e627ef0195c73824d803fcafa
4
+ data.tar.gz: fa1112813faf7c4d6d162d467b4977c7b800bc647d35c0fe6fec79a1f30e6112
5
+ SHA512:
6
+ metadata.gz: f9b5618853d0501a417fbcfd021e1dd65fe45fef42331d9f8b4f1c8ab273a91437f433b7065512b46340d98670e53127cb49a5149eb7078750bd99a0e6ad22fa
7
+ data.tar.gz: a9ce6040b23bcd4a2eb4f11bc10c50cd64ad8fdba685e2eba51f9181a3f3503617adb230b0601257e3f0ec3122c14d1a12b5091ab03b7612f906d72c54938189
data/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # pikuri-core
2
+
3
+ The lean, audit-friendly foundation of the [pikuri](https://codeberg.org/mvysny/pikuri)
4
+ AI-assistant toolkit:
5
+
6
+ - `Pikuri::Agent` — a thin wrapper around ruby_llm's chat loop with
7
+ the Configurator + Extension protocol for hosts to wire extra
8
+ capabilities into an agent.
9
+ - `Pikuri::Tool` framework with strict argument validation
10
+ (`Pikuri::Tool::Parameters`) and LLM-actionable error messages.
11
+ - Listener stream (`Pikuri::Agent::Listener::*`) for rendering,
12
+ token accounting, and structured capture.
13
+ - Controls (`StepLimit`, `Cancellable`, `Interloper`) for budget
14
+ enforcement + cancellation.
15
+ - Four stateless bundled tools: `CALCULATOR`, `WEB_SEARCH`,
16
+ `WEB_SCRAPE`, `FETCH`.
17
+ - A demo binary, `bin/pikuri-chat`.
18
+
19
+ Extensions (skills, MCP, workspace, coding stack, named-agent
20
+ personas) live in sibling gems and opt in à la carte — see
21
+ [`pikuri-skills`](../pikuri-skills/README.md),
22
+ [`pikuri-mcp`](../pikuri-mcp/README.md),
23
+ [`pikuri-workspace`](../pikuri-workspace/README.md),
24
+ [`pikuri-code`](../pikuri-code/README.md),
25
+ [`pikuri-assistant`](../pikuri-assistant/README.md). For the
26
+ convenience bundle that pulls in everything, see the
27
+ [`pikuri`](../pikuri/README.md) metagem.
28
+
29
+ ## Install
30
+
31
+ ```ruby
32
+ # Gemfile
33
+ gem 'pikuri-core'
34
+ ```
35
+
36
+ ```sh
37
+ gem install pikuri-core
38
+ ```
39
+
40
+ ## Minimal usage
41
+
42
+ ```ruby
43
+ require 'pikuri-core'
44
+
45
+ RubyLLM.configure do |c|
46
+ c.openai_api_base = 'http://localhost:8080/v1'
47
+ c.openai_api_key = 'not-needed'
48
+ end
49
+
50
+ agent = Pikuri::Agent.new(
51
+ transport: Pikuri::Agent::ChatTransport.new(
52
+ model: 'unsloth/Qwen3.6-35B-A3B-GGUF',
53
+ provider: :openai,
54
+ assume_model_exists: true
55
+ ),
56
+ system_prompt: Pikuri.prompt(:'pikuri-chat'),
57
+ step_limit: Pikuri::Agent::Control::StepLimit.new(max: 20)
58
+ ) do |c|
59
+ c.add_tool Pikuri::Tool::CALCULATOR
60
+ c.add_tool Pikuri::Tool::WEB_SEARCH
61
+ c.add_listener Pikuri::Agent::Listener::Terminal.new
62
+ end
63
+ agent.run_loop(user_message: 'What is 17 * 23?')
64
+ ```
65
+
66
+ See `bin/pikuri-chat` for a worked example with REPL, signal
67
+ handling, and cancellation.
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ class Agent
5
+ # The trio of arguments that has to travel together to +RubyLLM.chat+
6
+ # for model resolution to come out the same on every construction:
7
+ # the model id, the provider hint, and the registry-bypass flag.
8
+ #
9
+ # Bundling them is structural protection against a recurring bug
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.
16
+ #
17
+ # Pure data carrier: no +RubyLLM+ references here, so the seam stays
18
+ # in {Agent}, +bin/pikuri-chat+, and {Tool}.
19
+ #
20
+ # @!attribute [r] model
21
+ # @return [String, nil] LLM identifier; +nil+ defers to
22
+ # +RubyLLM.config.default_model+ at {Agent} construction time
23
+ # @!attribute [r] provider
24
+ # @return [Symbol, nil] forwarded to +RubyLLM.chat+. Required
25
+ # together with +assume_model_exists+ when pointing at a local
26
+ # OpenAI-compatible server (llama.cpp, gpustack, ...) whose model
27
+ # ids are not in ruby_llm's bundled registry.
28
+ # @!attribute [r] assume_model_exists
29
+ # @return [Boolean] forwarded to +RubyLLM.chat+; +true+ skips
30
+ # ruby_llm's registry lookup and trusts the supplied model id.
31
+ # Requires +provider+.
32
+ class ChatTransport < Data.define(:model, :provider, :assume_model_exists)
33
+ # @param model [String, nil]
34
+ # @param provider [Symbol, nil]
35
+ # @param assume_model_exists [Boolean]
36
+ def initialize(model:, provider: nil, assume_model_exists: false)
37
+ super
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ class Agent
5
+ # Build-time collector yielded into the +Pikuri::Agent.new+ block.
6
+ # Hosts and {Extension} implementations call its methods to declare
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.
10
+ #
11
+ # == Why this exists
12
+ #
13
+ # Splits "configure the agent" from "the agent's runtime state".
14
+ # Hosts can write a block that reads cleanly:
15
+ #
16
+ # Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
17
+ # c.add_listener Pikuri::Agent::Listener::Terminal.new
18
+ # c.add_tool Pikuri::Tool::CALCULATOR
19
+ # c.add_extension Pikuri::Skill::Extension.new(catalog: catalog)
20
+ # end
21
+ #
22
+ # Extensions implement their +configure(c)+ hook against the same
23
+ # type, so the call sites for "block users add stuff" and
24
+ # "extensions add stuff" share one API.
25
+ #
26
+ # == Lifecycle
27
+ #
28
+ # One +Configurator+ per +Agent.new+ invocation. The Configurator
29
+ # is constructed inside {Agent#initialize}, yielded to the block
30
+ # (if any), then its collected state is drained by the Agent body.
31
+ # The Configurator instance is discarded once +Agent.new+ returns
32
+ # — it carries no runtime state.
33
+ class Configurator
34
+ # @return [Agent::ChatTransport] same transport the Agent will
35
+ # use. Extensions read this to wire helpers consistently
36
+ # (e.g. an MCP description-synthesizer that calls the same
37
+ # model the agent itself uses).
38
+ attr_reader :transport
39
+
40
+ # @return [String] the +system_prompt:+ kwarg passed to
41
+ # {Agent#initialize}, untouched. Extensions append to the
42
+ # prompt via {#append_system_prompt} rather than mutating
43
+ # this; the attribute exists so a peek at the base is
44
+ # available for diagnostics.
45
+ attr_reader :system_prompt_base
46
+
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
50
+
51
+ # @return [Boolean] +true+ when the agent opted into chunk-level
52
+ # streaming.
53
+ attr_reader :streaming
54
+
55
+ # @return [Control::StepLimit, nil] step-budget control passed
56
+ # to the Agent ctor, or +nil+.
57
+ attr_reader :step_limit
58
+
59
+ # @return [Control::Cancellable, nil] cancellation control
60
+ # passed to the Agent ctor, or +nil+. Extensions that run
61
+ # sub-LLM calls during +configure+ (e.g. an MCP description
62
+ # synthesizer) share this so a user cancel during boot
63
+ # propagates correctly.
64
+ attr_reader :cancellable
65
+
66
+ # @return [Control::Interloper, nil] mid-loop user-input queue
67
+ # passed to the Agent ctor, or +nil+.
68
+ attr_reader :interloper
69
+
70
+ # @return [Array<Tool>] tools added via {#add_tool}, in
71
+ # declaration order. Drained by {Agent#initialize}.
72
+ attr_reader :tools
73
+
74
+ # @return [Array<Listener::Base>] listeners added via
75
+ # {#add_listener}, in declaration order. Drained by
76
+ # {Agent#initialize}.
77
+ attr_reader :listeners
78
+
79
+ # @return [Array<String>] system-prompt snippets added via
80
+ # {#append_system_prompt}, in declaration order. Joined with
81
+ # double-newline separators between the base prompt and each
82
+ # snippet by {Agent#initialize}.
83
+ attr_reader :system_prompt_additions
84
+
85
+ # @return [Array<Proc>] +on_close+ handlers added via
86
+ # {#on_close}, in declaration order. Fired by {Agent#close}
87
+ # in LIFO order with per-handler rescue.
88
+ attr_reader :on_close_handlers
89
+
90
+ # @return [Array<#configure>] extension instances added via
91
+ # {#add_extension}, in declaration order. The Agent ctor
92
+ # walks this list and calls +bind(self)+ on each after
93
+ # wiring is complete.
94
+ attr_reader :extensions
95
+
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
+ # @param transport [Agent::ChatTransport]
111
+ # @param system_prompt_base [String]
112
+ # @param name [String]
113
+ # @param streaming [Boolean]
114
+ # @param step_limit [Control::StepLimit, nil]
115
+ # @param cancellable [Control::Cancellable, nil]
116
+ # @param interloper [Control::Interloper, nil]
117
+ def initialize(transport:, system_prompt_base:, name:, streaming:,
118
+ step_limit:, cancellable:, interloper:)
119
+ @transport = transport
120
+ @system_prompt_base = system_prompt_base
121
+ @name = name
122
+ @streaming = streaming
123
+ @step_limit = step_limit
124
+ @cancellable = cancellable
125
+ @interloper = interloper
126
+
127
+ @tools = []
128
+ @listeners = []
129
+ @system_prompt_additions = []
130
+ @on_close_handlers = []
131
+ @extensions = []
132
+ end
133
+
134
+ # Append a tool to the agent's static tool list.
135
+ #
136
+ # @param tool [Tool]
137
+ # @return [void]
138
+ def add_tool(tool)
139
+ @tools << tool
140
+ nil
141
+ end
142
+
143
+ # Append several tools at once. Equivalent to calling {#add_tool}
144
+ # 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
+ #
148
+ # @param tools [Enumerable<Tool>]
149
+ # @return [void]
150
+ def add_tools(tools)
151
+ tools.each { |t| @tools << t }
152
+ nil
153
+ end
154
+
155
+ # Append a listener to the agent's listener list.
156
+ #
157
+ # @param listener [Listener::Base]
158
+ # @return [void]
159
+ def add_listener(listener)
160
+ @listeners << listener
161
+ nil
162
+ end
163
+
164
+ # Append several listeners at once. Accepts any enumerable
165
+ # (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
+ #
170
+ # @param listeners [Enumerable<Listener::Base>]
171
+ # @return [void]
172
+ def add_listeners(listeners)
173
+ listeners.each { |l| @listeners << l }
174
+ nil
175
+ end
176
+
177
+ # Append a snippet to the system prompt. Snippets are joined
178
+ # to the base prompt with double-newline separators.
179
+ #
180
+ # Used by extensions to register +<available_skills>+,
181
+ # +<available_mcps>+, and similar advertisement blocks. Block
182
+ # users typically pass the full system prompt via the ctor's
183
+ # +system_prompt:+ kwarg instead.
184
+ #
185
+ # @param snippet [String]
186
+ # @return [void]
187
+ def append_system_prompt(snippet)
188
+ @system_prompt_additions << snippet
189
+ nil
190
+ end
191
+
192
+ # Register an extension. The extension's +configure(self)+ is
193
+ # called immediately so source-order matches execution-order.
194
+ # The instance is also retained for the +bind(agent)+ sweep
195
+ # that runs at the end of {Agent#initialize}.
196
+ #
197
+ # Extensions must implement both +configure+ and +bind+. The
198
+ # easy way is to +include Pikuri::Agent::Extension+ — that
199
+ # mixes in empty defaults for both, so an extension overrides
200
+ # only what it cares about and leaves the other as a no-op.
201
+ #
202
+ # @param extension [Extension, #configure, #bind] extension instance
203
+ # @return [void]
204
+ def add_extension(extension)
205
+ @extensions << extension
206
+ extension.configure(self)
207
+ nil
208
+ end
209
+
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
+ # Register a handler called by {Agent#close}. Handlers fire in
231
+ # LIFO order, each inside its own +rescue+ — same semantics as
232
+ # +ensure+-block cleanup discipline.
233
+ #
234
+ # @yield called with no arguments at close time
235
+ # @return [void]
236
+ def on_close(&blk)
237
+ raise ArgumentError, 'on_close requires a block' unless block_given?
238
+
239
+ @on_close_handlers << blk
240
+ nil
241
+ 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
+ end
269
+ end
270
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module Pikuri
7
+ class Agent
8
+ # Resolves the model's context-window cap from three sources, in order:
9
+ # an explicit override, the value ruby_llm reports for the model, or a
10
+ # llama.cpp +/props+ probe. Returns +nil+ if none of those produce a
11
+ # value.
12
+ #
13
+ # Used by {Agent#initialize} at construction time to feed
14
+ # {Listener::TokenLog} a cap it can render alongside the running
15
+ # context size (so the +ctx=12.2k/32.0k+ line tells the operator how
16
+ # close the conversation is to the limit).
17
+ #
18
+ # == Precedence
19
+ #
20
+ # 1. +override+ — the +Agent.new(context_window:)+ kwarg. Wins over
21
+ # everything; an explicit value is the operator's statement of
22
+ # truth.
23
+ # 2. +ruby_llm_reported+ — +RubyLLM::Model::Info#context_window+ from
24
+ # {Agent#chat}'s resolved model. Populated for models in ruby_llm's
25
+ # bundled registry (OpenAI, Anthropic, Gemini, …); +nil+ for custom
26
+ # local model ids that fall through to +Model::Info.default+.
27
+ # 3. +llama_probe_url+ — HTTP GET against llama.cpp's non-standard
28
+ # +/props+ endpoint. The server exposes the launched +n_ctx+ at
29
+ # +default_generation_settings.n_ctx+ there. Probed only when the
30
+ # first two are +nil+. Provider-specific to llama.cpp; the caller
31
+ # (typically +bin/pikuri-chat+) derives the right URL from its configured
32
+ # base.
33
+ #
34
+ # == Failure handling
35
+ #
36
+ # The probe is best-effort. HTTP error, timeout, non-JSON body, or a
37
+ # missing/invalid +n_ctx+ field all return +nil+ and log one +warn+
38
+ # line via +Pikuri.logger_for('ContextWindowDetector')+. This is the
39
+ # CLAUDE.md "secondary to the loop" carve-out — a wedged or
40
+ # non-llama.cpp server should not abort agent construction over a
41
+ # cosmetic readout.
42
+ class ContextWindowDetector
43
+ # Subsystem logger; set its level with
44
+ # +PIKURI_LOG_CONTEXTWINDOWDETECTOR+ or the global +PIKURI_LOG+.
45
+ #
46
+ # @return [Logger]
47
+ LOGGER = Pikuri.logger_for('ContextWindowDetector')
48
+
49
+ # Connect timeout in seconds for the llama.cpp +/props+ probe.
50
+ # Short on purpose: this runs synchronously during +Agent.new+ and
51
+ # a wedged server should not stall startup noticeably.
52
+ #
53
+ # @return [Integer]
54
+ OPEN_TIMEOUT = 2
55
+ # Read timeout in seconds for the llama.cpp +/props+ probe; matches
56
+ # {OPEN_TIMEOUT} for the same reason.
57
+ #
58
+ # @return [Integer]
59
+ READ_TIMEOUT = 2
60
+
61
+ # @param override [Integer, nil] explicit cap from the caller; wins if
62
+ # non-+nil+
63
+ # @param ruby_llm_reported [Integer, nil] value off
64
+ # +RubyLLM::Chat#model.context_window+
65
+ # @param llama_probe_url [String, nil] full URL to llama.cpp +/props+;
66
+ # +nil+ or empty string skips the probe
67
+ def initialize(override:, ruby_llm_reported:, llama_probe_url:)
68
+ @override = override
69
+ @ruby_llm_reported = ruby_llm_reported
70
+ @llama_probe_url = llama_probe_url
71
+ end
72
+
73
+ # @return [Integer, nil] resolved cap, or +nil+ if no source produced
74
+ # one
75
+ def detect
76
+ return @override if @override
77
+ return @ruby_llm_reported if @ruby_llm_reported
78
+ return nil if @llama_probe_url.nil? || @llama_probe_url.empty?
79
+
80
+ probe_llama_cpp
81
+ end
82
+
83
+ private
84
+
85
+ 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
91
+
92
+ return warn_and_nil("HTTP #{response.status} from #{@llama_probe_url}") unless response.status == 200
93
+
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?
97
+
98
+ warn_and_nil(
99
+ "no positive integer at default_generation_settings.n_ctx in #{@llama_probe_url} response"
100
+ )
101
+ rescue Faraday::Error, JSON::ParserError => e
102
+ warn_and_nil("#{e.class.name.split('::').last}: #{e.message}")
103
+ end
104
+
105
+ def warn_and_nil(reason)
106
+ LOGGER.warn("llama.cpp /props probe failed: #{reason}")
107
+ nil
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ class Agent
5
+ module Control
6
+ # Cooperative cancellation token. The instance is normally
7
+ # constructed on the main thread and handed to
8
+ # {Agent#initialize} via the +cancellable:+ kwarg; an
9
+ # out-of-band caller (a SIGINT trap, a TUI key binding, an
10
+ # IPC handler) calls {#cancel!} to flip the flag, and the
11
+ # next {#check!} on the run thread raises {Cancelled} —
12
+ # which {Agent#run_loop} catches, normalizes into an
13
+ # {Event::Cancelled} on the listener stream, and re-raises
14
+ # so the caller's REPL can return control to the user.
15
+ #
16
+ # == Cancellation boundary
17
+ #
18
+ # The +Agent+ calls {#check!} from its +before_tool_call+
19
+ # wiring — between an LLM response that requested a tool
20
+ # and the actual tool invocation — which is the only point
21
+ # at which the conversation state is consistent (no
22
+ # in-flight subprocess, no half-applied write). An in-flight
23
+ # LLM HTTP call is *not* interrupted; the response lands,
24
+ # then the next tool-call boundary trips. An in-flight tool
25
+ # (notably +Bash+) is also not interrupted — cancellation
26
+ # lands after the tool returns. Both are intentional v1
27
+ # scope: the "gentle cancel" semantic that pikuri promises.
28
+ #
29
+ # == Thread safety
30
+ #
31
+ # {#cancel!} is intended to be called from a thread other
32
+ # than the one running {Agent#run_loop} (the typical case
33
+ # is a SIGINT trap handler on the main thread while the
34
+ # agent runs on a worker, or vice versa). A plain boolean
35
+ # ivar is sufficient under MRI: writes and reads of a
36
+ # single reference are atomic with respect to the GVL, and
37
+ # the only state transition we care about is +false → true+
38
+ # before the next {#check!} fires. There is no double-cancel
39
+ # hazard; repeated {#cancel!} calls are idempotent.
40
+ #
41
+ # == Sub-agent semantics
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.
50
+ class Cancellable
51
+ # Raised by {#check!} once {#cancel!} has been called.
52
+ # Carries no fields; the cancellation reason ("the user
53
+ # asked us to stop") is implicit in the exception class.
54
+ # {Agent#run_loop} catches this, emits {Event::Cancelled},
55
+ # and re-raises so the caller (typically a REPL) can
56
+ # return control to the user.
57
+ class Cancelled < StandardError
58
+ def initialize
59
+ super('Agent loop cancelled')
60
+ end
61
+ end
62
+
63
+ def initialize
64
+ @cancelled = false
65
+ end
66
+
67
+ # Flip the flag. Safe to call from a thread other than
68
+ # the one running the agent loop, and safe to call
69
+ # multiple times (idempotent). Takes effect at the next
70
+ # {#check!} on the run thread — see the class header for
71
+ # the "gentle cancel" caveats.
72
+ #
73
+ # @return [void]
74
+ def cancel!
75
+ @cancelled = true
76
+ end
77
+
78
+ # @return [Boolean] whether {#cancel!} has been called
79
+ # since the last {#reset!}; observable from any thread.
80
+ def cancelled?
81
+ @cancelled
82
+ end
83
+
84
+ # Raise {Cancelled} when the flag is set; otherwise no-op.
85
+ # Called by {Agent} from its +before_tool_call+ wiring.
86
+ #
87
+ # @return [void]
88
+ # @raise [Cancelled] when {#cancel!} has been called since
89
+ # the last {#reset!}
90
+ def check!
91
+ raise Cancelled if @cancelled
92
+ end
93
+
94
+ # Reset the flag back to armed. Called by {Agent} at the
95
+ # start of each turn so a stale cancellation from a prior
96
+ # turn does not poison the next one. Mid-loop
97
+ # {Control::Interloper} injections deliberately do *not*
98
+ # trigger a reset — otherwise the cancel-then-inject
99
+ # ordering would lose the cancellation: +cancel!+ sets
100
+ # the flag, the injection lands and resets it, and the
101
+ # next +before_tool_call+ no longer raises.
102
+ #
103
+ # @return [void]
104
+ def reset!
105
+ @cancelled = false
106
+ end
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
+ # @return [String] short label for {Agent#to_s}; reflects
119
+ # the current flag state so a startup banner or debug
120
+ # print can tell an armed token apart from one that has
121
+ # already tripped.
122
+ def to_s
123
+ "Cancellable(#{@cancelled ? 'cancelled' : 'armed'})"
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end