pikuri-core 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +67 -0
- data/lib/pikuri/agent/chat_transport.rb +41 -0
- data/lib/pikuri/agent/configurator.rb +270 -0
- data/lib/pikuri/agent/context_window_detector.rb +111 -0
- data/lib/pikuri/agent/control/cancellable.rb +128 -0
- data/lib/pikuri/agent/control/interloper.rb +167 -0
- data/lib/pikuri/agent/control/step_limit.rb +93 -0
- data/lib/pikuri/agent/control.rb +45 -0
- data/lib/pikuri/agent/event.rb +190 -0
- data/lib/pikuri/agent/extension.rb +82 -0
- data/lib/pikuri/agent/listener/in_memory_event_list.rb +34 -0
- data/lib/pikuri/agent/listener/rate_limited.rb +172 -0
- data/lib/pikuri/agent/listener/terminal.rb +264 -0
- data/lib/pikuri/agent/listener/token_log.rb +216 -0
- data/lib/pikuri/agent/listener.rb +54 -0
- data/lib/pikuri/agent/listener_list.rb +102 -0
- data/lib/pikuri/agent/synthesizer.rb +145 -0
- data/lib/pikuri/agent.rb +731 -0
- data/lib/pikuri/subprocess.rb +166 -0
- data/lib/pikuri/tool/calculator.rb +82 -0
- data/lib/pikuri/tool/fetch.rb +171 -0
- data/lib/pikuri/tool/parameters.rb +314 -0
- data/lib/pikuri/tool/scraper/fetch_error.rb +16 -0
- data/lib/pikuri/tool/scraper/html.rb +285 -0
- data/lib/pikuri/tool/scraper/pdf.rb +54 -0
- data/lib/pikuri/tool/scraper/simple.rb +183 -0
- data/lib/pikuri/tool/search/brave.rb +184 -0
- data/lib/pikuri/tool/search/duckduckgo.rb +196 -0
- data/lib/pikuri/tool/search/engines.rb +163 -0
- data/lib/pikuri/tool/search/exa.rb +217 -0
- data/lib/pikuri/tool/search/rate_limiter.rb +92 -0
- data/lib/pikuri/tool/search/result.rb +29 -0
- data/lib/pikuri/tool/sub_agent.rb +150 -0
- data/lib/pikuri/tool/web_scrape.rb +121 -0
- data/lib/pikuri/tool/web_search.rb +38 -0
- data/lib/pikuri/tool.rb +118 -0
- data/lib/pikuri/url_cache.rb +112 -0
- data/lib/pikuri/version.rb +10 -0
- data/lib/pikuri-core.rb +177 -0
- data/prompts/pikuri-chat.txt +15 -0
- metadata +251 -0
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
|