prompt_builder 0.1.2 → 0.3.0

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.
@@ -27,6 +27,25 @@ module PromptBuilder
27
27
  JSONIFY_FIELDS = %i[include tool_choice metadata text stream_options reasoning].freeze
28
28
  private_constant :JSONIFY_FIELDS
29
29
 
30
+ # Effort levels accepted by +think+. This is the union of the levels
31
+ # recognized across serializers; each serializer omits levels its target
32
+ # API does not support.
33
+ THINK_EFFORT_LEVELS = %w[minimal low medium high xhigh max].freeze
34
+ private_constant :THINK_EFFORT_LEVELS
35
+
36
+ # All keyword options accepted by +initialize+.
37
+ #
38
+ # This constant is public API — unlike the private field-group constants
39
+ # above, it must not be marked with +private_constant+. Integrating gems
40
+ # use it to validate or partition option hashes before constructing a
41
+ # Session, e.g. +options.slice(*PromptBuilder::Session::INITIALIZE_OPTIONS)+.
42
+ #
43
+ # @api public
44
+ # @return [Array<Symbol>] the supported keyword option names
45
+ INITIALIZE_OPTIONS = (
46
+ STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS + %i[input extra system]
47
+ ).freeze
48
+
30
49
  # @!attribute [rw] model
31
50
  # @return [String, nil] the model identifier
32
51
  # @!attribute [rw] instructions
@@ -109,10 +128,44 @@ module PromptBuilder
109
128
  # @return [Integer] the index in +items+ marking the boundary after the last response
110
129
  attr_reader :response_boundary_index
111
130
 
112
- # @return [Hash, nil] provider-specific extra data for serializers.
113
- # Recognized keys vary by target format. Unrecognized keys are silently
114
- # ignored by each serializer.
115
- attr_reader :extra
131
+ # Restore the response boundary, e.g. when deserializing a session. The
132
+ # value is clamped to the current item count. Normally the boundary is
133
+ # maintained by +add_response+; use this only to reconstruct state.
134
+ #
135
+ # @param index [Integer] the boundary index
136
+ # @return [Integer]
137
+ def response_boundary_index=(index)
138
+ @response_boundary_index = index.to_i.clamp(0, @items.length)
139
+ end
140
+
141
+ # Provider-specific extra data for serializers. Always returns a Hash, empty
142
+ # when nothing is set. Keys are always Strings.
143
+ #
144
+ # The returned Hash is a copy, so mutating it does not change the session.
145
+ # To add or remove a key, modify a copy and assign it back:
146
+ #
147
+ # session.extra = session.extra.merge("guardrail_config" => {"guardrailIdentifier" => "gr-1"})
148
+ #
149
+ # Recognized keys vary by target format. Unrecognized keys are silently
150
+ # ignored by each serializer.
151
+ #
152
+ # @return [Hash] a copy of the provider-specific extra data
153
+ def extra
154
+ PromptBuilder.jsonify(@extra)
155
+ end
156
+
157
+ # Replace the provider-specific extra data. Keys are deep-stringified with
158
+ # +PromptBuilder.jsonify+ and the value is copied, so the assigned Hash is
159
+ # not shared with the session. Pass +nil+ to clear.
160
+ #
161
+ # @param value [Hash, nil] the extra data, or nil to clear it
162
+ # @return [Hash] the stored extra data
163
+ # @raise [ArgumentError] if the value is neither a Hash nor nil
164
+ def extra=(value)
165
+ raise ArgumentError, "extra must be a Hash" unless value.nil? || value.is_a?(Hash)
166
+
167
+ @extra = PromptBuilder.jsonify(value || {})
168
+ end
116
169
 
117
170
  class << self
118
171
  # Deserialize a Session from a Hash produced by +to_h+ or parsed JSON.
@@ -125,7 +178,7 @@ module PromptBuilder
125
178
  def from_h(hash)
126
179
  attrs = (STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS)
127
180
  .each_with_object({}) { |f, acc| acc[f] = hash[f.to_s] }
128
- attrs[:extra] = hash["extra"] if hash["extra"]
181
+ attrs[:extra] = hash["extra"]
129
182
  session = new(**attrs)
130
183
 
131
184
  Array(hash["input"]).each do |item_hash|
@@ -138,6 +191,8 @@ module PromptBuilder
138
191
  session.register_tool(defn.name, description: defn.description, parameters: defn.parameters, strict: defn.strict, **extra)
139
192
  end
140
193
 
194
+ session.response_boundary_index = hash["response_boundary_index"] if hash["response_boundary_index"]
195
+
141
196
  session
142
197
  end
143
198
  end
@@ -145,19 +200,33 @@ module PromptBuilder
145
200
  # Create a new Session with the given options.
146
201
  # Accepts keyword arguments for all typed field groups (STRING_FIELDS,
147
202
  # FLOAT_FIELDS, INTEGER_FIELDS, BOOLEAN_FIELDS, JSONIFY_FIELDS); all default
148
- # to +nil+. The +input+ shorthand auto-creates a user message if provided.
203
+ # to +nil+. The +system+ and +input+ shorthands auto-create a system and
204
+ # user message if provided. Unsupported keyword options raise an ArgumentError.
149
205
  #
150
206
  # @param attributes [Hash] keyword options; see attribute declarations above
207
+ # @option attributes [String, nil] :system optional string shorthand; a system
208
+ # message is automatically added with this text
151
209
  # @option attributes [String, nil] :input optional string shorthand; a user
152
210
  # message is automatically added with this text
153
211
  # @option attributes [Hash, nil] :extra provider-specific extra data for
154
- # serializers; recognized keys vary by target format
212
+ # serializers; recognized keys vary by target format. Keys are stringified.
213
+ # Defaults to an empty Hash and can be replaced later with +extra=+.
214
+ # @raise [ArgumentError] if an unsupported option is passed
155
215
  def initialize(**attributes)
216
+ unsupported = attributes.keys - INITIALIZE_OPTIONS
217
+ unless unsupported.empty?
218
+ raise ArgumentError, "unsupported option#{"s" if unsupported.size > 1}: #{unsupported.join(", ")}"
219
+ end
220
+
156
221
  (STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS).each do |f|
157
222
  send(:"#{f}=", attributes[f])
158
223
  end
159
- @extra = PromptBuilder.jsonify(attributes[:extra]) if attributes[:extra]
224
+
225
+ self.extra = attributes[:extra]
226
+
160
227
  @items = []
228
+ system(attributes[:system]) if attributes[:system]
229
+
161
230
  @tool_definitions = {}
162
231
  @response_boundary_index = 0
163
232
  user(attributes[:input]) if attributes[:input]
@@ -171,6 +240,7 @@ module PromptBuilder
171
240
  # session.user("Hello, how are you?")
172
241
  # session.user(Content::InputText.new(text: "Hello, how are you?"))
173
242
  # session.user(type: "input_text", text: "Hello, how are you?")
243
+ # session.user(text: "Hello, how are you?") # type defaults to "input_text"
174
244
  # session.user([
175
245
  # Content::InputText.new(text: "What is in this image?"),
176
246
  # Content::InputImage.new(url: "http://example.com/image.png")
@@ -195,6 +265,10 @@ module PromptBuilder
195
265
  #
196
266
  # @param content [String, Content::Base, Hash, Array<Content::Base>, Array<Hash>] the message content
197
267
  # @return [Items::Message] the added message
268
+ # @example
269
+ # session.system("You are a helpful assistant.")
270
+ # session.system(text: "You are a helpful assistant.") # type defaults to "input_text"
271
+ # session.system(text: "You are a helpful assistant.", cache_point: true, cache_control: {type: "ephemeral"})
198
272
  def system(content)
199
273
  add_item(Items::Message.new(role: "system", content: content))
200
274
  end
@@ -247,6 +321,19 @@ module PromptBuilder
247
321
  @response_boundary_index = @items.length
248
322
  end
249
323
 
324
+ # Clear all conversation items and the system instructions, returning the
325
+ # session to a fresh local-state start. Model configuration and registered
326
+ # tools are preserved.
327
+ #
328
+ # @return [self]
329
+ def clear
330
+ @items.clear
331
+ self.instructions = nil
332
+ self.previous_response_id = nil
333
+ @response_boundary_index = 0
334
+ self
335
+ end
336
+
250
337
  # Register a tool on this session.
251
338
  #
252
339
  # @param name [String] the tool name
@@ -263,7 +350,7 @@ module PromptBuilder
263
350
  strict: strict,
264
351
  **extra
265
352
  )
266
- @tool_definitions[name] = definition
353
+ @tool_definitions[name.to_s] = definition
267
354
  definition
268
355
  end
269
356
 
@@ -286,6 +373,143 @@ module PromptBuilder
286
373
  end
287
374
  end
288
375
 
376
+ # Copy tool definitions from a ToolRegistry onto this session by name.
377
+ # With no names, all tools in the registry are copied (same as
378
+ # +register_tools+). Definitions are copied, so later registry changes do
379
+ # not affect the session and the tools survive +to_h+/+from_h+ round-trips.
380
+ #
381
+ # @param names [Array<String, Symbol>] the tool names to copy; empty for all
382
+ # @param registry [ToolRegistry, nil] the registry to copy from; defaults
383
+ # to the global +PromptBuilder.tool_registry+
384
+ # @return [Array<Tools::Definition>] the definitions registered on the session
385
+ # @raise [ToolNotFoundError] if a name is not registered in the registry
386
+ # @example
387
+ # session.use_tools("weather", "traffic_conditions")
388
+ # session.use_tools # all registry tools
389
+ # session.use_tools(:weather, registry: my_registry)
390
+ def use_tools(*names, registry: nil)
391
+ registry ||= PromptBuilder.tool_registry
392
+ raise ArgumentError, "registry must be an instance of ToolRegistry" unless registry.is_a?(ToolRegistry)
393
+
394
+ definitions = if names.empty?
395
+ registry.definitions
396
+ else
397
+ names.map do |name|
398
+ registry.definition_for(name.to_s) ||
399
+ raise(ToolNotFoundError, "No tool registered with name: #{name.to_s.inspect}")
400
+ end
401
+ end
402
+
403
+ definitions.map do |defn|
404
+ extra = defn.extra.transform_keys(&:to_sym)
405
+ register_tool(
406
+ defn.name,
407
+ description: defn.description,
408
+ parameters: defn.parameters,
409
+ strict: defn.strict,
410
+ **extra
411
+ )
412
+ end
413
+ end
414
+
415
+ # Remove a single registered tool by name. Accepts a string or symbol and
416
+ # matches regardless of how the tool's key was stored.
417
+ #
418
+ # @param name [String, Symbol] the tool name
419
+ # @return [Tools::Definition, nil] the removed definition, or nil if not found
420
+ def remove_tool(name)
421
+ key = name.to_s
422
+ removed = nil
423
+ @tool_definitions.delete_if do |k, defn|
424
+ match = k.to_s == key
425
+ removed = defn if match
426
+ match
427
+ end
428
+ removed
429
+ end
430
+
431
+ # Remove all registered tools from the session.
432
+ #
433
+ # @return [Array<Tools::Definition>] the removed tool definitions
434
+ def clear_tools
435
+ removed = @tool_definitions.values
436
+ @tool_definitions.clear
437
+ removed
438
+ end
439
+
440
+ # Configure JSON Schema structured output. Writes the canonical
441
+ # +text.format+ wire hash consumed by all serializers, preserving any
442
+ # other +text+ keys (e.g. +verbosity+) already set.
443
+ #
444
+ # @param schema [Hash] the JSON Schema for the response
445
+ # @param name [String] the schema name
446
+ # @param strict [Boolean, nil] whether strict schema adherence is requested;
447
+ # omitted from the format when nil
448
+ # @param description [String, nil] an optional schema description
449
+ # @return [Hash] the resulting +text+ configuration
450
+ # @example
451
+ # session.json_output({"type" => "object", "properties" => {...}}, strict: true)
452
+ def json_output(schema, name: "response", strict: nil, description: nil)
453
+ format = {"type" => "json_schema", "name" => name.to_s, "schema" => schema}
454
+ format["strict"] = strict unless strict.nil?
455
+ format["description"] = description.to_s if description
456
+ self.text = (text || {}).merge("format" => format)
457
+ end
458
+
459
+ # Configure reasoning/extended thinking portably across serializers.
460
+ # Stores a normalized +reasoning+ configuration; each serializer maps it
461
+ # to its native parameter:
462
+ #
463
+ # - +effort+ — Messages (+output_config.effort+), Chat Completions
464
+ # (+reasoning_effort+), Gemini (+thinkingConfig.thinkingLevel+), and
465
+ # Open Responses (+reasoning.effort+)
466
+ # - +budget_tokens+ — Messages (+thinking.budget_tokens+) and Gemini
467
+ # (+thinkingConfig.thinkingBudget+); ignored by effort-based APIs
468
+ # - Converse supports neither and warns once at serialization time
469
+ #
470
+ # Pass either +effort+ or +budget_tokens+, not both — providers reject
471
+ # requests that set both controls. Direct +session.reasoning = {...}+
472
+ # assignment keeps working for provider-specific keys.
473
+ #
474
+ # @param enabled [Boolean] pass +false+ to clear the reasoning configuration
475
+ # @param effort [String, Symbol, nil] a portable effort level; one of
476
+ # +minimal+, +low+, +medium+, +high+, +xhigh+, +max+ (unsupported levels
477
+ # are omitted by serializers whose target API does not accept them)
478
+ # @param budget_tokens [Integer, nil] an explicit thinking token budget
479
+ # @return [Hash, nil] the resulting +reasoning+ configuration
480
+ # @raise [ArgumentError] if neither or both of +effort+ and +budget_tokens+
481
+ # are given when enabling, or the values are invalid
482
+ # @example
483
+ # session.think(effort: :medium) # portable across serializers
484
+ # session.think(budget_tokens: 8_000) # explicit where supported
485
+ # session.think(false) # disable
486
+ def think(enabled = true, effort: nil, budget_tokens: nil)
487
+ unless enabled
488
+ raise ArgumentError, "cannot combine think(false) with effort or budget_tokens" if effort || budget_tokens
489
+
490
+ return self.reasoning = nil
491
+ end
492
+
493
+ if effort && budget_tokens
494
+ raise ArgumentError, "pass either effort or budget_tokens, not both"
495
+ elsif effort.nil? && budget_tokens.nil?
496
+ raise ArgumentError, "think requires effort: or budget_tokens:"
497
+ end
498
+
499
+ if effort
500
+ effort = effort.to_s
501
+ unless THINK_EFFORT_LEVELS.include?(effort)
502
+ raise ArgumentError, "effort must be one of: #{THINK_EFFORT_LEVELS.join(", ")}"
503
+ end
504
+ self.reasoning = {"effort" => effort}
505
+ else
506
+ budget_tokens = Integer(budget_tokens)
507
+ raise ArgumentError, "budget_tokens must be positive" unless budget_tokens.positive?
508
+
509
+ self.reasoning = {"budget_tokens" => budget_tokens}
510
+ end
511
+ end
512
+
289
513
  # Return all tool definitions registered on this session.
290
514
  #
291
515
  # @return [Array<Tools::Definition>] all tool definitions
@@ -332,6 +556,7 @@ module PromptBuilder
332
556
 
333
557
  h["input"] = @items.map(&:to_h) unless @items.empty?
334
558
  h["previous_response_id"] = @previous_response_id if @previous_response_id
559
+ h["response_boundary_index"] = @response_boundary_index if @response_boundary_index.positive?
335
560
 
336
561
  h["tools"] = tool_definitions.map(&:to_h) unless @tool_definitions.empty?
337
562
 
@@ -355,7 +580,7 @@ module PromptBuilder
355
580
  val = send(f)
356
581
  h[f.to_s] = val if val
357
582
  }
358
- h["extra"] = @extra if @extra
583
+ h["extra"] = extra unless @extra.empty?
359
584
 
360
585
  h
361
586
  end
@@ -376,7 +601,7 @@ module PromptBuilder
376
601
  def config_hash
377
602
  h = (STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS - %i[previous_response_id])
378
603
  .each_with_object({}) { |f, acc| acc[f] = send(f) }
379
- h[:extra] = @extra if @extra
604
+ h[:extra] = @extra
380
605
  h
381
606
  end
382
607
  end
@@ -36,6 +36,4 @@ Gem::Specification.new do |spec|
36
36
  spec.require_paths = ["lib"]
37
37
 
38
38
  spec.required_ruby_version = ">= 3.0"
39
-
40
- spec.add_development_dependency "bundler"
41
39
  end
metadata CHANGED
@@ -1,28 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prompt_builder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: bundler
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :development
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '0'
11
+ dependencies: []
26
12
  email:
27
13
  - bbdurand@gmail.com
28
14
  executables: []