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.
@@ -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
- result[name] = coerce(symbolized[name], schema[:type])
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 << "Missing required parameter `#{name}` (#{schema[:type]}): #{schema[:description]}"
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
- " - `#{name}` (#{prop[:type]}, #{req}): #{prop[:description]}"
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 a +String+
62
- # observation
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. Recoverable failures should be
71
- # returned as +"Error: <message>"+ Strings rather than raised — see
72
- # "Error handling convention" above.
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, or +"Error: ..."+ on validation
88
- # failure
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)
@@ -6,5 +6,5 @@ module Pikuri
6
6
  # additions to the public surface (+Pikuri::Tool+ / +Pikuri::Agent+ /
7
7
  # listeners / bundled tools), major for breaking changes to that
8
8
  # surface or to the +bin/pikuri-*+ CLIs.
9
- VERSION = '0.0.3'
9
+ VERSION = '0.0.4'
10
10
  end
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.3
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-22 00:00:00.000000000 Z
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