pikuri-core 0.0.3 → 0.0.4
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 +50 -75
- data/lib/pikuri/agent/control/cancellable.rb +7 -17
- data/lib/pikuri/agent/control/interloper.rb +10 -21
- data/lib/pikuri/agent/control/step_limit.rb +0 -14
- data/lib/pikuri/agent/extension.rb +12 -14
- 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 +88 -96
- data/lib/pikuri/file_type.rb +237 -0
- data/lib/pikuri/subprocess.rb +9 -2
- data/lib/pikuri/tool/parameters.rb +64 -3
- data/lib/pikuri/tool.rb +15 -7
- data/lib/pikuri/version.rb +1 -1
- metadata +3 -3
- data/lib/pikuri/tool/sub_agent.rb +0 -150
|
@@ -133,6 +133,38 @@ module Pikuri
|
|
|
133
133
|
add(name, 'boolean', description, required: false)
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
# Add a required +enum+ property — a +string+ field constrained
|
|
137
|
+
# to one of a fixed set of values. Emits JSON-Schema +enum+
|
|
138
|
+
# alongside +type: 'string'+, which the LLM treats as a closed
|
|
139
|
+
# choice. Validation rejects any string outside the set with an
|
|
140
|
+
# LLM-actionable error message listing the allowed values.
|
|
141
|
+
#
|
|
142
|
+
# @param name [Symbol] property name
|
|
143
|
+
# @param description [String] human-readable description shown to the LLM
|
|
144
|
+
# @param values [Array<String>] non-empty list of allowed values;
|
|
145
|
+
# each entry must be a non-empty String. The list is +dup+'d
|
|
146
|
+
# and frozen at insertion so callers can't mutate it later.
|
|
147
|
+
# @return [self]
|
|
148
|
+
# @raise [ArgumentError] if +values+ is not a non-empty Array of
|
|
149
|
+
# non-empty Strings (build-time check — surfaces as a host-side
|
|
150
|
+
# bug rather than an LLM-facing validation error)
|
|
151
|
+
def required_enum(name, description, values:)
|
|
152
|
+
add_enum(name, description, values, required: true)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Add an optional +enum+ property. See {#required_enum} for the
|
|
156
|
+
# +values+ contract and validation behavior.
|
|
157
|
+
#
|
|
158
|
+
# @param name [Symbol] property name
|
|
159
|
+
# @param description [String] human-readable description shown to the LLM
|
|
160
|
+
# @param values [Array<String>] non-empty list of allowed values
|
|
161
|
+
# @return [self]
|
|
162
|
+
# @raise [ArgumentError] if +values+ is not a non-empty Array of
|
|
163
|
+
# non-empty Strings
|
|
164
|
+
def optional_enum(name, description, values:)
|
|
165
|
+
add_enum(name, description, values, required: false)
|
|
166
|
+
end
|
|
167
|
+
|
|
136
168
|
# Schema in OpenAI JSON-Schema shape.
|
|
137
169
|
#
|
|
138
170
|
# @return [Hash] +{type: 'object', properties: {...}, required: [...]}+
|
|
@@ -168,12 +200,15 @@ module Pikuri
|
|
|
168
200
|
@properties.each do |name, schema|
|
|
169
201
|
if symbolized.key?(name)
|
|
170
202
|
begin
|
|
171
|
-
|
|
203
|
+
coerced = coerce(symbolized[name], schema[:type])
|
|
204
|
+
raise CoercionError, enum_message(schema[:enum], coerced) if schema[:enum] && !schema[:enum].include?(coerced)
|
|
205
|
+
|
|
206
|
+
result[name] = coerced
|
|
172
207
|
rescue CoercionError => e
|
|
173
208
|
errors << "Parameter `#{name}` #{e.message}."
|
|
174
209
|
end
|
|
175
210
|
elsif @required.include?(name.to_s)
|
|
176
|
-
errors <<
|
|
211
|
+
errors << missing_required_message(name, schema)
|
|
177
212
|
end
|
|
178
213
|
end
|
|
179
214
|
|
|
@@ -195,6 +230,22 @@ module Pikuri
|
|
|
195
230
|
self
|
|
196
231
|
end
|
|
197
232
|
|
|
233
|
+
def add_enum(name, description, values, required:)
|
|
234
|
+
unless values.is_a?(Array) && !values.empty? &&
|
|
235
|
+
values.all? { |v| v.is_a?(String) && !v.empty? }
|
|
236
|
+
raise ArgumentError,
|
|
237
|
+
"values: must be a non-empty Array of non-empty Strings, got #{values.inspect}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
@properties[name] = {
|
|
241
|
+
type: 'string',
|
|
242
|
+
enum: values.dup.freeze,
|
|
243
|
+
description: description
|
|
244
|
+
}
|
|
245
|
+
@required << name.to_s if required
|
|
246
|
+
self
|
|
247
|
+
end
|
|
248
|
+
|
|
198
249
|
# Coerce +value+ to a Ruby value matching the JSON-Schema +type+,
|
|
199
250
|
# returning the coerced value. Raises {CoercionError} on failure.
|
|
200
251
|
def coerce(value, type)
|
|
@@ -284,6 +335,15 @@ module Pikuri
|
|
|
284
335
|
"must be #{article} #{type} (got #{value.class}: #{value.inspect})"
|
|
285
336
|
end
|
|
286
337
|
|
|
338
|
+
def enum_message(values, got)
|
|
339
|
+
"must be one of #{values.map { |v| "`#{v}`" }.join(', ')} (got String: #{got.inspect})"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def missing_required_message(name, schema)
|
|
343
|
+
enum_part = schema[:enum] ? ", one of: #{schema[:enum].map { |v| "`#{v}`" }.join(', ')}" : ''
|
|
344
|
+
"Missing required parameter `#{name}` (#{schema[:type]}#{enum_part}): #{schema[:description]}"
|
|
345
|
+
end
|
|
346
|
+
|
|
287
347
|
def unknown_key_error(unknown)
|
|
288
348
|
suggestion = DidYouMean::SpellChecker
|
|
289
349
|
.new(dictionary: @properties.keys.map(&:to_s))
|
|
@@ -305,7 +365,8 @@ module Pikuri
|
|
|
305
365
|
'Expected schema:',
|
|
306
366
|
*@properties.map { |name, prop|
|
|
307
367
|
req = @required.include?(name.to_s) ? 'required' : 'optional'
|
|
308
|
-
|
|
368
|
+
enum_part = prop[:enum] ? ", one of: #{prop[:enum].map { |v| "`#{v}`" }.join(', ')}" : ''
|
|
369
|
+
" - `#{name}` (#{prop[:type]}, #{req}#{enum_part}): #{prop[:description]}"
|
|
309
370
|
}
|
|
310
371
|
].join("\n")
|
|
311
372
|
end
|
data/lib/pikuri/tool.rb
CHANGED
|
@@ -58,8 +58,13 @@ module Pikuri
|
|
|
58
58
|
attr_reader :parameters
|
|
59
59
|
|
|
60
60
|
# @return [Proc] callable invoked once arguments have been validated;
|
|
61
|
-
# receives validated keyword arguments and returns
|
|
62
|
-
#
|
|
61
|
+
# receives validated keyword arguments and returns the observation.
|
|
62
|
+
# The usual return is a +String+; tools that want to deliver
|
|
63
|
+
# multimodal observations (e.g. {Pikuri::Workspace::Read} on a
|
|
64
|
+
# PNG) may instead return a +RubyLLM::Content+ with attachments,
|
|
65
|
+
# which +RubyLLM::Chat#handle_tool_calls+ accepts via its
|
|
66
|
+
# +content_like?+ check and the per-provider Media formatters turn
|
|
67
|
+
# into the right image / document blocks inside +tool_result+.
|
|
63
68
|
attr_reader :execute
|
|
64
69
|
|
|
65
70
|
# @param name [String] function name advertised to the LLM
|
|
@@ -67,9 +72,10 @@ module Pikuri
|
|
|
67
72
|
# to decide when to call the tool
|
|
68
73
|
# @param parameters [Tool::Parameters] declared schema
|
|
69
74
|
# @param execute [Proc] callable invoked with validated keyword arguments
|
|
70
|
-
# that returns a +String+ observation
|
|
71
|
-
#
|
|
72
|
-
# "Error
|
|
75
|
+
# that returns either a +String+ observation or a +RubyLLM::Content+
|
|
76
|
+
# (for multimodal observations — see {#execute}). Recoverable failures
|
|
77
|
+
# should be returned as +"Error: <message>"+ Strings rather than raised
|
|
78
|
+
# — see "Error handling convention" above.
|
|
73
79
|
# @return [Tool]
|
|
74
80
|
def initialize(name:, description:, parameters:, execute:)
|
|
75
81
|
@name = name
|
|
@@ -84,8 +90,10 @@ module Pikuri
|
|
|
84
90
|
# to the LLM as the next observation; everything else bubbles up.
|
|
85
91
|
#
|
|
86
92
|
# @param args [Hash] raw arguments supplied by the LLM
|
|
87
|
-
# @return [String] tool observation
|
|
88
|
-
#
|
|
93
|
+
# @return [String, RubyLLM::Content] tool observation. Validation
|
|
94
|
+
# failures always come back as +"Error: ..."+ Strings; the success
|
|
95
|
+
# shape is whatever the +execute+ Proc returns (typically a String,
|
|
96
|
+
# or a +RubyLLM::Content+ for multimodal observations).
|
|
89
97
|
def run(args)
|
|
90
98
|
validated = @parameters.validate(args)
|
|
91
99
|
@execute.call(**validated)
|
data/lib/pikuri/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pikuri-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Martin Vysny
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: dentaku
|
|
@@ -199,6 +199,7 @@ files:
|
|
|
199
199
|
- lib/pikuri/agent/listener/token_log.rb
|
|
200
200
|
- lib/pikuri/agent/listener_list.rb
|
|
201
201
|
- lib/pikuri/agent/synthesizer.rb
|
|
202
|
+
- lib/pikuri/file_type.rb
|
|
202
203
|
- lib/pikuri/subprocess.rb
|
|
203
204
|
- lib/pikuri/tool.rb
|
|
204
205
|
- lib/pikuri/tool/calculator.rb
|
|
@@ -214,7 +215,6 @@ files:
|
|
|
214
215
|
- lib/pikuri/tool/search/exa.rb
|
|
215
216
|
- lib/pikuri/tool/search/rate_limiter.rb
|
|
216
217
|
- lib/pikuri/tool/search/result.rb
|
|
217
|
-
- lib/pikuri/tool/sub_agent.rb
|
|
218
218
|
- lib/pikuri/tool/web_scrape.rb
|
|
219
219
|
- lib/pikuri/tool/web_search.rb
|
|
220
220
|
- lib/pikuri/url_cache.rb
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Pikuri
|
|
4
|
-
class Tool
|
|
5
|
-
# The +sub_agent+ tool, expressed as a {Tool} subclass: instantiating
|
|
6
|
-
# +Tool::SubAgent.new(parent_agent)+ produces a tool whose
|
|
7
|
-
# {Tool#to_ruby_llm_tool} wiring is identical to any bundled tool's,
|
|
8
|
-
# so ruby_llm sees nothing special about it. When the model calls it,
|
|
9
|
-
# the closure inside +execute+ spawns a fresh {Agent} that runs its
|
|
10
|
-
# own Thought / Tool-call / Observation loop on a clean message
|
|
11
|
-
# history, then returns only the sub-agent's final assistant message
|
|
12
|
-
# back as the parent's next observation.
|
|
13
|
-
#
|
|
14
|
-
# The sub-agent reuses the parent's +transport+, +system_prompt+,
|
|
15
|
-
# +context_window_cap+, and +name+ (as its hierarchical prefix), so
|
|
16
|
-
# it shares the same persona, hits the same server, and inherits the
|
|
17
|
-
# same context-window cap without re-probing. Its tool list is a
|
|
18
|
-
# snapshot of the parent's {Agent#tools} taken at construction —
|
|
19
|
-
# {Agent#allow_sub_agent} only appends the sub-agent tool to its own
|
|
20
|
-
# +@tools+ *after* this snapshot, so the sub-agent's tool list never
|
|
21
|
-
# contains itself (recursion guard).
|
|
22
|
-
#
|
|
23
|
-
# Its listener list comes from the parent's {Agent#listeners} via
|
|
24
|
-
# {Agent::ListenerList#for_sub_agent}, which forwards to each
|
|
25
|
-
# listener's own +for_sub_agent+ hook: +Terminal+ swaps to a padded
|
|
26
|
-
# fresh instance, +TokenLog+ resets its snapshot, and listeners
|
|
27
|
-
# without the hook ({Agent::Listener::InMemoryEventList}, …) are
|
|
28
|
-
# shared by reference so structured capture flows continuously.
|
|
29
|
-
#
|
|
30
|
-
# Controls are derived per the per-control rule: a fresh
|
|
31
|
-
# {Agent::Control::StepLimit} at the new cap (mutable counter is
|
|
32
|
-
# per-chat), the same {Agent::Control::Cancellable} shared by
|
|
33
|
-
# reference (one +cancel!+ stops the whole tree), and no
|
|
34
|
-
# {Agent::Control::Interloper} (the host has no handle to
|
|
35
|
-
# sub-agents).
|
|
36
|
-
#
|
|
37
|
-
# All parent state is captured by value at construction — the closure
|
|
38
|
-
# does not chase +parent_agent+ mutations later. The one piece of
|
|
39
|
-
# mutable state is a monotonic counter used to generate sub-agent ids:
|
|
40
|
-
# +"sub_agent 0"+, +"sub_agent 1"+, ... at the top level; nested
|
|
41
|
-
# children of +"sub_agent 0"+ are +"sub_agent 0_0"+, +"sub_agent 0_1"+,
|
|
42
|
-
# ... — the +"sub_agent "+ prefix appears once at the top and the
|
|
43
|
-
# underscore-separated counter chain records depth.
|
|
44
|
-
class SubAgent < Tool
|
|
45
|
-
# Description shown to the LLM. Follows the opencode-shape (summary
|
|
46
|
-
# + +Usage:+ bullets) prescribed by the project's tool-description
|
|
47
|
-
# convention.
|
|
48
|
-
#
|
|
49
|
-
# @return [String]
|
|
50
|
-
DESCRIPTION = <<~DESC
|
|
51
|
-
Delegate a self-contained task to a fresh sub-agent that runs its own Thought / Tool-call / Observation loop on a clean conversation, returning only its final assistant message.
|
|
52
|
-
|
|
53
|
-
Usage:
|
|
54
|
-
- Use to isolate side-quests — research, multi-step lookups, exploratory tool use — so intermediate observations do not clutter your own context.
|
|
55
|
-
- The sub-agent has your tools minus `sub_agent` itself, so it cannot recurse.
|
|
56
|
-
- It shares your system prompt — persona, tool-use conventions, and output format carry over. Do NOT re-explain who you are or how to use tools.
|
|
57
|
-
- It cannot see your conversation. Put ALL task-specific context inside `task`; the sub-agent has zero memory of what came before.
|
|
58
|
-
DESC
|
|
59
|
-
|
|
60
|
-
# @param parent_agent [Agent] the calling agent. Read for its
|
|
61
|
-
# {Agent#transport}, {Agent#system_prompt}, {Agent#tools},
|
|
62
|
-
# {Agent#listeners}, {Agent#step_limit}, {Agent#cancellable},
|
|
63
|
-
# {Agent#context_window_cap}, {Agent#name}, and
|
|
64
|
-
# {Agent#extensions} (so the sub-agent inherits and re-binds
|
|
65
|
-
# the parent's extension list).
|
|
66
|
-
# @param max_steps [Integer] step budget for each sub-agent run,
|
|
67
|
-
# used to construct the sub-agent's own
|
|
68
|
-
# {Agent::Control::StepLimit}.
|
|
69
|
-
# @return [SubAgent]
|
|
70
|
-
def initialize(parent_agent, max_steps: 10)
|
|
71
|
-
transport = parent_agent.transport
|
|
72
|
-
system_prompt = parent_agent.system_prompt
|
|
73
|
-
sub_tools = parent_agent.tools.dup
|
|
74
|
-
listeners = parent_agent.listeners
|
|
75
|
-
parent_step_limit = parent_agent.step_limit
|
|
76
|
-
parent_cancel = parent_agent.cancellable
|
|
77
|
-
context_window = parent_agent.context_window_cap
|
|
78
|
-
parent_name = parent_agent.name
|
|
79
|
-
streaming = parent_agent.streaming
|
|
80
|
-
# Parent's extension list, captured at SubAgent construction
|
|
81
|
-
# so spawned sub-agents share the *same* extension instances
|
|
82
|
-
# (configure has already run on the parent — the resulting
|
|
83
|
-
# tools / snippets / listeners are inherited verbatim via
|
|
84
|
-
# the kwargs above). Each inherited extension's +bind+ fires
|
|
85
|
-
# inside the sub-agent's +Agent#initialize+ — that's how
|
|
86
|
-
# MCP's per-agent connect tool ends up keyed to the
|
|
87
|
-
# sub-agent rather than the parent, while still sharing the
|
|
88
|
-
# parent's live MCP clients through the extension instance.
|
|
89
|
-
# See IDEAS.md §"Sub-agent inheritance — configure-once,
|
|
90
|
-
# bind-per-agent".
|
|
91
|
-
inherited_exts = parent_agent.extensions
|
|
92
|
-
sub_counter = 0
|
|
93
|
-
|
|
94
|
-
super(
|
|
95
|
-
name: 'sub_agent',
|
|
96
|
-
description: DESCRIPTION,
|
|
97
|
-
parameters: Parameters.build { |p|
|
|
98
|
-
p.required_string :task,
|
|
99
|
-
'Self-contained instructions for the sub-agent, ' \
|
|
100
|
-
'e.g. "Find the populations of Reykjavik and ' \
|
|
101
|
-
'Helsinki in 2024 and report both numbers." ' \
|
|
102
|
-
'It has no access to the parent conversation, ' \
|
|
103
|
-
'so include all necessary context.'
|
|
104
|
-
},
|
|
105
|
-
execute: lambda { |task:|
|
|
106
|
-
idx = sub_counter
|
|
107
|
-
sub_counter += 1
|
|
108
|
-
sub_name = parent_name.empty? ? "sub_agent #{idx}" : "#{parent_name}_#{idx}"
|
|
109
|
-
sub_listeners = listeners.for_sub_agent(name: sub_name)
|
|
110
|
-
|
|
111
|
-
# All inherited state is seeded through the Configurator
|
|
112
|
-
# block — tools and listeners via add_tools / add_listeners,
|
|
113
|
-
# extensions via inherit_extensions which retains them for
|
|
114
|
-
# the bind sweep without re-running configure (the parent
|
|
115
|
-
# already drove that and the resulting system-prompt
|
|
116
|
-
# snippets are inherited verbatim through +system_prompt+).
|
|
117
|
-
sub = Agent.new(
|
|
118
|
-
transport: transport,
|
|
119
|
-
system_prompt: system_prompt,
|
|
120
|
-
step_limit: parent_step_limit&.for_sub_agent(max_steps: max_steps),
|
|
121
|
-
cancellable: parent_cancel&.for_sub_agent,
|
|
122
|
-
context_window: context_window,
|
|
123
|
-
name: sub_name,
|
|
124
|
-
streaming: streaming
|
|
125
|
-
) do |c|
|
|
126
|
-
c.add_tools(sub_tools)
|
|
127
|
-
c.add_listeners(sub_listeners)
|
|
128
|
-
c.inherit_extensions(inherited_exts)
|
|
129
|
-
end
|
|
130
|
-
begin
|
|
131
|
-
sub.run_loop(user_message: task)
|
|
132
|
-
sub.last_assistant_content
|
|
133
|
-
ensure
|
|
134
|
-
# The sub-agent borrows the parent's MCP clients via
|
|
135
|
-
# the shared {Mcp::Extension} instance; it doesn't own
|
|
136
|
-
# them. {#close} still fires its own +on_close+ list
|
|
137
|
-
# (empty for a sub-agent — no extensions registered
|
|
138
|
-
# any handlers via the inherited path, since they only
|
|
139
|
-
# re-bind here, not re-configure), so this is a no-op
|
|
140
|
-
# today. Calling +#close+ anyway means any future
|
|
141
|
-
# sub-agent-owned resource gets released without
|
|
142
|
-
# revisiting this site. See {Agent#close}.
|
|
143
|
-
sub.close
|
|
144
|
-
end
|
|
145
|
-
}
|
|
146
|
-
)
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
end
|