prompt_builder 0.1.2 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e762383ea51918fa77b5962f0137775551a37da1c228a37d24364011c8d21f73
4
- data.tar.gz: 64e2a4e22feb0a8e0e5752f4807b79452c3cb700dd8b5740500f11cbe0b4217e
3
+ metadata.gz: 9eee723a908587c28aa7e606b0e6dad45e5958a9d895d29bd42f8d984153d6bc
4
+ data.tar.gz: ffbfa13cc4bdfacc09ace7fe99a46366847faa501db6710cd51cca7123c106b2
5
5
  SHA512:
6
- metadata.gz: b7eb3a4806f9cd7a24e00652659b7a5d641ae5877187bd714afd0e4eaa86c7a7901eebcfdf11202497d0b98cce03f9a0fec10f1840883f91b4b87be3a9f2c387
7
- data.tar.gz: 2b731def4d25b2233b208d3fb3d8867ee17cce9356cb84b3d57d3d3043116af5eb55ca7888de00c329c3637b209bbc70ea11dada040bc5f97f9701c85a0fa8ee
6
+ metadata.gz: 40023bf88305a391c88b8a189e2810e27ad5af96b2e5bbf3697296eb9b4f63a360239eed8022c343198ddeff09573874988a33372b40b7c15080603a7588f093
7
+ data.tar.gz: b9be150ab138503f29f7769f71d1ad8092cef42aa5ba04ecb4d156f070fa5dc9aecdc1995a31fabfa74ab93206d47c1a24428417e2055d999c4473669c335f59
data/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 0.2.0
8
+
9
+ ### Added
10
+
11
+ - `Session#use_tools` copies tool definitions from a `ToolRegistry` (the global registry by default) onto the session by name, raising a `ToolNotFoundError` for unknown names.
12
+ - `Session#json_output` configures JSON Schema structured output without writing the raw `text.format` wire hash by hand.
13
+ - `Session#think` configures reasoning portably across serializers via `effort:` or `budget_tokens:`; `think(false)` clears the configuration.
14
+ - `Response#parsed_json` parses the response text as JSON (stripping fenced ```json wrappers), returning `nil` when it cannot be parsed; `Response#parsed_json!` raises a `ParseError` including the raw text instead.
15
+ - `Response.from_text` synthesizes a completed assistant text response for canned answers, cached responses, and tests.
16
+ - `PromptBuilder::ParseError` error class.
17
+
18
+ ### Changed
19
+
20
+ - `Session.new` raises an `ArgumentError` when passed an unsupported keyword option instead of silently ignoring it.
21
+ - The Messages serializer raises an `UnsupportedFormatError` when `session.max_output_tokens` is not set, since the Messages API requires the `max_tokens` parameter.
22
+ - Serializers now raise an `UnsupportedFormatError` for missing fields their target API requires instead of emitting an invalid payload: Chat Completions, Converse, and Open Responses require `session.model`; Chat Completions requires at least one message; Gemini requires a non-empty `contents` array.
23
+ - The Messages serializer raises an `UnsupportedFormatError` when thinking is enabled with a `budget_tokens` that is not less than `max_output_tokens`, matching the Anthropic API requirement that `max_tokens` be greater than the thinking budget.
24
+ - The Converse serializer warns once per process when `session.reasoning` is set instead of silently dropping it (the Converse endpoint has no reasoning parameter).
25
+
7
26
  ## 0.1.2
8
27
 
9
28
  ### Fixed
data/README.md CHANGED
@@ -57,6 +57,8 @@ session = PromptBuilder::Session.new(
57
57
  )
58
58
  ```
59
59
 
60
+ Passing an option the constructor doesn't recognize (or both halves of an alias pair) raises an `ArgumentError`.
61
+
60
62
  ### Conversation History
61
63
 
62
64
  Build up a multi-turn conversation by adding messages:
@@ -160,6 +162,52 @@ response.has_tool_calls? # => false
160
162
  response.usage # => #<PromptBuilder::Usage input_tokens=25 output_tokens=12 ...>
161
163
  ```
162
164
 
165
+ You can also synthesize a plain-text response without calling an API — useful for canned answers (e.g. halting an agent loop), cached responses, and tests:
166
+
167
+ ```ruby
168
+ response = PromptBuilder::Response.from_text("Authentication failed.",
169
+ model: llm_response.model, usage: llm_response.usage)
170
+ response.text # => "Authentication failed."
171
+ response.completed? # => true
172
+ ```
173
+
174
+ ### Structured Output
175
+
176
+ Use `json_output` to request JSON Schema structured output and `parsed_json` to read it back:
177
+
178
+ ```ruby
179
+ schema = {
180
+ "type" => "object",
181
+ "properties" => {"answer" => {"type" => "string"}},
182
+ "required" => ["answer"]
183
+ }
184
+
185
+ session.json_output(schema, name: "response", strict: true)
186
+ payload = session.request_payload(:messages) # works with all five serializers
187
+
188
+ # After parsing the API response:
189
+ data = response.parsed_json # => {"answer" => "..."} or nil if the text isn't valid JSON
190
+ data = response.parsed_json! # raises PromptBuilder::ParseError (including the raw text) instead
191
+ ```
192
+
193
+ `parsed_json` strips fenced ```` ```json ```` wrappers, a common provider quirk. `json_output` is sugar for setting `session.text` to the canonical `format` wire hash, so direct `session.text = {...}` assignment keeps working.
194
+
195
+ ### Reasoning / Extended Thinking
196
+
197
+ Use `think` to configure reasoning portably; each serializer maps it to its native parameter:
198
+
199
+ ```ruby
200
+ session.think(effort: :medium) # portable across serializers
201
+ session.think(budget_tokens: 8_000) # explicit token budget where supported
202
+ session.think(false) # clear the reasoning configuration
203
+ ```
204
+
205
+ - `effort:` (one of `minimal`, `low`, `medium`, `high`, `xhigh`, `max`) maps to `output_config.effort` (Messages), `reasoning_effort` (Chat Completions), `thinkingConfig.thinkingLevel` (Gemini), and `reasoning.effort` (Open Responses). Levels a target API doesn't accept are omitted.
206
+ - `budget_tokens:` maps to `thinking.budget_tokens` (Messages) and `thinkingConfig.thinkingBudget` (Gemini); effort-based APIs ignore it. The Messages serializer raises an `UnsupportedFormatError` at `request_payload` time when `max_output_tokens` is not greater than the budget, since the Anthropic API requires `max_tokens > budget_tokens`.
207
+ - The Converse API has no reasoning parameter; the serializer warns once per process and omits the configuration.
208
+
209
+ Direct `session.reasoning = {...}` assignment keeps working for provider-specific keys (e.g. Anthropic's `display`, Gemini's `summary`).
210
+
163
211
  ### Agentic Tool Loops
164
212
 
165
213
  You can register tool definitions on a session, add API responses to the conversation, and manually append tool outputs to build an agentic loop:
@@ -240,6 +288,16 @@ end
240
288
  session.register_tools(PromptBuilder.tool_registry)
241
289
  ```
242
290
 
291
+ Use `use_tools` to attach a subset of registry tools by name without copying schemas by hand. Unknown names raise a `ToolNotFoundError`, so a typo fails fast instead of producing a silently absent tool:
292
+
293
+ ```ruby
294
+ session.use_tools("weather", "traffic_conditions") # from PromptBuilder.tool_registry
295
+ session.use_tools # all tools in the registry
296
+ session.use_tools(:weather, registry: my_registry) # explicit registry
297
+ ```
298
+
299
+ Tool definitions are *copied* onto the session in all cases, so later registry changes don't affect the session and the tools survive `to_h`/`from_h` round-trips.
300
+
243
301
  ### Content Types
244
302
 
245
303
  Message content can be a plain string or an array of structured content objects for multi-modal input. Content can be provided as raw Hashes or as `PromptBuilder::Content` objects.
@@ -481,6 +539,9 @@ restored_session = PromptBuilder::Session.from_h(hash)
481
539
 
482
540
  This makes it straightforward to persist conversation state in a database or cache between requests.
483
541
 
542
+ > [!WARNING]
543
+ > `to_h` is the persistence format, not a request payload. It retains provider-specific `extra` data and non-replayable items that APIs reject. Send the output of `request_payload(serializer)` to APIs, and use `to_h`/`from_h` only for storage.
544
+
484
545
  ## Serializer Compatibility
485
546
 
486
547
  The Open Responses format is the canonical data model for this gem. When serializing to other formats, some features may not be available because either the target API does not support them or because the Open Responses format does not expose parameters unique to the target API. If you attempt to use a feature that is not supported by a particular serializer, it will be silently omitted from the serialized output.
@@ -601,6 +662,8 @@ For Messages specifically:
601
662
 
602
663
  ### Chat Completions-specific notes
603
664
 
665
+ Serializing a session without `model` set, or one that produces no messages at all, raises a `PromptBuilder::UnsupportedFormatError`.
666
+
604
667
  Request-side mappings worth calling out:
605
668
 
606
669
  | Canonical field / value | Chat Completions mapping |
@@ -631,6 +694,8 @@ Response-side behavior and limitations:
631
694
 
632
695
  ### Anthropic Messages-specific mappings
633
696
 
697
+ The Messages API requires the `max_tokens` parameter, which is populated from `session.max_output_tokens`. Serializing a session without `max_output_tokens` set raises a `PromptBuilder::UnsupportedFormatError`.
698
+
634
699
  A few features map between the canonical Open Responses format and the Messages API in non-obvious ways:
635
700
 
636
701
  | Canonical field / value | Messages mapping |
@@ -654,6 +719,8 @@ Built-in tool response content blocks (`server_tool_use`, `web_search_tool_resul
654
719
 
655
720
  ### Gemini-specific notes
656
721
 
722
+ Serializing a session without `model` set (the model belongs in the request URL, but it is validated at serialization time), or one that produces an empty `contents` array, raises a `PromptBuilder::UnsupportedFormatError`.
723
+
657
724
  Request-side mappings worth calling out:
658
725
 
659
726
  | Canonical field / value | Gemini mapping |
@@ -697,6 +764,8 @@ Response-side limitations:
697
764
 
698
765
  ### Converse-specific notes
699
766
 
767
+ Serializing a session without `model` set (populates the required `modelId` parameter) raises a `PromptBuilder::UnsupportedFormatError`.
768
+
700
769
  Request-side restrictions worth calling out:
701
770
 
702
771
  | Canonical field / value | Converse mapping |
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.2
1
+ 0.2.0
@@ -18,4 +18,7 @@ module PromptBuilder
18
18
 
19
19
  # Raised when a response payload does not match the expected shape.
20
20
  class UnexpectedPayloadError < Error; end
21
+
22
+ # Raised by Response#parsed_json! when the response text is not valid JSON.
23
+ class ParseError < Error; end
21
24
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module PromptBuilder
4
6
  # Represents a parsed API response from the Open Responses API.
5
7
  # All fields are optional and will be nil if not present in the response.
@@ -173,6 +175,23 @@ module PromptBuilder
173
175
  attrs = FIELDS.each_with_object({}) { |f, acc| acc[f] = hash[f.to_s] }
174
176
  new(**attrs, text_config: hash["text"], output: output, usage: usage, extra: hash["extra"])
175
177
  end
178
+
179
+ # Synthesize a Response containing a single assistant text message.
180
+ # Useful for canned answers (halt messages, cached responses) and tests
181
+ # without assembling Items::Message and Content::OutputText by hand.
182
+ #
183
+ # @param text [String] the assistant message text
184
+ # @param status [String] the response status
185
+ # @param attributes [Hash] any other Response attributes (e.g. +model+,
186
+ # +usage+, +id+)
187
+ # @return [Response]
188
+ # @example
189
+ # PromptBuilder::Response.from_text("Authentication failed.",
190
+ # model: llm_response.model, usage: llm_response.usage)
191
+ def from_text(text, status: "completed", **attributes)
192
+ message = Items::Message.new(role: "assistant", content: [Content::OutputText.new(text: text)])
193
+ new(status: status, output: [message], **attributes)
194
+ end
176
195
  end
177
196
 
178
197
  # Check if the response completed successfully.
@@ -224,6 +243,40 @@ module PromptBuilder
224
243
  nil
225
244
  end
226
245
 
246
+ # Parse the response text as JSON, for use with structured output
247
+ # (see +Session#json_output+). Strips a fenced ```json wrapper when
248
+ # present — a common provider quirk. Returns nil when the response has
249
+ # no text or the text is not valid JSON; use +parsed_json!+ to raise
250
+ # instead.
251
+ #
252
+ # @return [Hash, Array, Object, nil] the parsed JSON value, or nil
253
+ def parsed_json
254
+ raw = text
255
+ return nil unless raw
256
+
257
+ begin
258
+ JSON.parse(strip_json_fences(raw))
259
+ rescue JSON::ParserError
260
+ nil
261
+ end
262
+ end
263
+
264
+ # Parse the response text as JSON, raising when it cannot be parsed.
265
+ #
266
+ # @return [Hash, Array, Object] the parsed JSON value
267
+ # @raise [ParseError] if the response has no text or the text is not
268
+ # valid JSON; the error message includes the raw text
269
+ def parsed_json!
270
+ raw = text
271
+ raise ParseError, "Response has no text output to parse as JSON" unless raw
272
+
273
+ begin
274
+ JSON.parse(strip_json_fences(raw))
275
+ rescue JSON::ParserError => e
276
+ raise ParseError, "Response text is not valid JSON (#{e.message}); raw text: #{raw}"
277
+ end
278
+ end
279
+
227
280
  # Serialize to a Hash with string keys. Nil values are omitted.
228
281
  #
229
282
  # @return [Hash]
@@ -246,6 +299,12 @@ module PromptBuilder
246
299
 
247
300
  private
248
301
 
302
+ def strip_json_fences(raw)
303
+ stripped = raw.strip
304
+ match = stripped.match(/\A```(?:json)?\s*\n(.*?)\n?```\z/m)
305
+ match ? match[1] : stripped
306
+ end
307
+
249
308
  def coerce_field(field, value)
250
309
  return value if value.nil?
251
310
 
@@ -5,6 +5,11 @@ module PromptBuilder
5
5
  class ChatCompletion < Base
6
6
  # Request serializer for the OpenAI Chat Completions API format.
7
7
  #
8
+ # Required session fields (an UnsupportedFormatError is raised when missing):
9
+ # - +model+
10
+ # - at least one message (+instructions+ or a conversation item that
11
+ # serializes to a message)
12
+ #
8
13
  # === Unsupported Open Responses features
9
14
  #
10
15
  # These session fields are not supported and are silently omitted from the
@@ -62,8 +67,14 @@ module PromptBuilder
62
67
 
63
68
  def serialize_request(session)
64
69
  h = {}
65
- h["model"] = session.model if session.model
66
- h["messages"] = build_messages(session)
70
+ raise UnsupportedFormatError, "Chat Completions format requires session.model" unless session.model
71
+
72
+ h["model"] = session.model
73
+ messages = build_messages(session)
74
+ if messages.empty?
75
+ raise UnsupportedFormatError, "Chat Completions format requires at least one message"
76
+ end
77
+ h["messages"] = messages
67
78
  h["temperature"] = session.temperature if session.temperature
68
79
  h["top_p"] = session.top_p if session.top_p
69
80
  h["presence_penalty"] = session.presence_penalty if session.presence_penalty
@@ -8,6 +8,9 @@ module PromptBuilder
8
8
  class Converse < Base
9
9
  # Request serializer for the Amazon Bedrock Converse API format.
10
10
  #
11
+ # Required session fields (an UnsupportedFormatError is raised when missing):
12
+ # - +model+ — populates the required +modelId+ parameter
13
+ #
11
14
  # === Unsupported Open Responses features
12
15
  #
13
16
  # These session fields are not supported and are silently omitted from the
@@ -19,7 +22,8 @@ module PromptBuilder
19
22
  # - +parallel_tool_calls+ — parallel tool call control is not supported
20
23
  # - +presence_penalty+ — not supported by the Converse API
21
24
  # - +prompt_cache_key+ / +prompt_cache_retention+ — explicit prompt cache keys are not supported
22
- # - +reasoning+ — extended thinking is not supported on the Converse endpoint
25
+ # - +reasoning+ — extended thinking is not supported on the Converse endpoint;
26
+ # a warning is issued (once per process) when +session.reasoning+ is set
23
27
  # - +safety_identifier+ — no equivalent user-safety field on the Converse endpoint
24
28
  # - +store+ — server-side response storage is not supported
25
29
  # - +stream+ — SSE streaming is handled outside the Converse request payload
@@ -110,6 +114,13 @@ module PromptBuilder
110
114
  }.freeze
111
115
 
112
116
  class << self
117
+ # Reset the once-per-process reasoning warning. Primarily used in tests.
118
+ #
119
+ # @return [void]
120
+ def reset_reasoning_warning!
121
+ @reasoning_warning_issued = false
122
+ end
123
+
113
124
  private
114
125
 
115
126
  def serialize_request(session)
@@ -118,7 +129,11 @@ module PromptBuilder
118
129
  ctx = {document_name_counts: Hash.new(0)}
119
130
 
120
131
  h = {}
121
- h["modelId"] = session.model if session.model
132
+ raise UnsupportedFormatError, "Converse format requires session.model" unless session.model
133
+
134
+ warn_reasoning_unsupported! if session.reasoning
135
+
136
+ h["modelId"] = session.model
122
137
 
123
138
  system = build_system(session)
124
139
  h["system"] = system unless system.empty?
@@ -154,6 +169,16 @@ module PromptBuilder
154
169
  h
155
170
  end
156
171
 
172
+ # The Converse endpoint has no reasoning/extended thinking parameter,
173
+ # so the reasoning config is dropped. Warn once per process instead of
174
+ # dropping it silently.
175
+ def warn_reasoning_unsupported!
176
+ return if @reasoning_warning_issued
177
+
178
+ @reasoning_warning_issued = true
179
+ warn "PromptBuilder: session.reasoning is not supported by the Converse format and was omitted from the request payload"
180
+ end
181
+
157
182
  def apply_session_extra!(h, extra)
158
183
  if extra.key?("stop_sequences")
159
184
  inference_config = h["inferenceConfig"] ||= {}
@@ -7,6 +7,11 @@ module PromptBuilder
7
7
  class Gemini < Base
8
8
  # Request serializer for the Google Gemini API format.
9
9
  #
10
+ # Required session fields (an UnsupportedFormatError is raised when missing):
11
+ # - +model+ — not part of the payload (it belongs in the request URL) but
12
+ # validated so an incomplete session fails at serialization time
13
+ # - at least one user/model message (the +contents+ array must not be empty)
14
+ #
10
15
  # === Unsupported Open Responses features
11
16
  #
12
17
  # These session fields are not supported and are silently omitted from the
@@ -115,7 +120,11 @@ module PromptBuilder
115
120
  system_instruction = build_system_instruction(session)
116
121
  h["systemInstruction"] = system_instruction if system_instruction
117
122
 
118
- h["contents"] = build_contents(session)
123
+ contents = build_contents(session)
124
+ if contents.empty?
125
+ raise UnsupportedFormatError, "Gemini format requires at least one user/model message"
126
+ end
127
+ h["contents"] = contents
119
128
 
120
129
  generation_config = build_generation_config(session)
121
130
  h["generationConfig"] = generation_config unless generation_config.empty?
@@ -5,6 +5,10 @@ module PromptBuilder
5
5
  class Messages < Base
6
6
  # Request serializer for the Anthropic Messages API format.
7
7
  #
8
+ # Required session fields (an UnsupportedFormatError is raised when missing):
9
+ # - +model+
10
+ # - +max_output_tokens+ — populates the required +max_tokens+ parameter
11
+ #
8
12
  # === Unsupported Open Responses features
9
13
  #
10
14
  # These session fields are not supported and are silently omitted from the
@@ -27,7 +31,9 @@ module PromptBuilder
27
31
  # - +service_tier+ — only +auto+ and +standard_only+ are accepted
28
32
  # - +text+ — +format.type=json_schema+ is mapped to +output_config.format+
29
33
  # - +reasoning+ — +budget_tokens+, +display+, +effort+, and +type+ are forwarded;
30
- # +temperature+ must be unset and +top_p+ must be >= 0.95 when reasoning is enabled
34
+ # +temperature+ must be unset, +top_p+ must be >= 0.95, and
35
+ # +max_output_tokens+ must be greater than +budget_tokens+ when reasoning
36
+ # is enabled
31
37
  #
32
38
  # Input content restrictions:
33
39
  # - +InputVideo+ content is not supported and is omitted
@@ -95,7 +101,11 @@ module PromptBuilder
95
101
  raise UnsupportedFormatError, "Messages format requires session.model" unless session.model
96
102
 
97
103
  h["model"] = session.model
98
- h["max_tokens"] = session.max_output_tokens if session.max_output_tokens
104
+ unless session.max_output_tokens
105
+ raise UnsupportedFormatError,
106
+ "Messages format requires session.max_output_tokens to populate the required max_tokens field"
107
+ end
108
+ h["max_tokens"] = session.max_output_tokens
99
109
  h["temperature"] = session.temperature if session.temperature
100
110
  h["top_p"] = session.top_p if session.top_p
101
111
  effective_metadata = build_effective_metadata(session)
@@ -249,6 +259,12 @@ module PromptBuilder
249
259
  def validate_thinking_compatibility!(session, thinking)
250
260
  return unless thinking
251
261
 
262
+ budget = thinking["budget_tokens"]
263
+ if budget && session.max_output_tokens && session.max_output_tokens <= budget
264
+ raise UnsupportedFormatError,
265
+ "Messages format requires max_output_tokens (max_tokens) to be greater than reasoning.budget_tokens"
266
+ end
267
+
252
268
  if session.temperature
253
269
  raise UnsupportedFormatError,
254
270
  "Messages format does not support temperature when thinking is enabled"
@@ -4,6 +4,9 @@ module PromptBuilder
4
4
  module Serializers
5
5
  class OpenResponses < Base
6
6
  # Request serializer for the OpenAI Open Responses API format.
7
+ #
8
+ # Required session fields (an UnsupportedFormatError is raised when missing):
9
+ # - +model+
7
10
  class Request < Base
8
11
  # Content extra keys that are part of the Open Responses API schema and
9
12
  # must survive extra-stripping (the canonical content classes have no
@@ -20,6 +23,8 @@ module PromptBuilder
20
23
  # @param session [Session] the session to export
21
24
  # @return [Hash] the serialized request payload
22
25
  def request_payload(session)
26
+ raise UnsupportedFormatError, "Open Responses format requires session.model" unless session.model
27
+
23
28
  payload = session.to_h
24
29
  items = apply_server_state!(payload, session)
25
30
  payload.delete("extra")
@@ -27,6 +27,19 @@ 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
+ INITIALIZE_OPTIONS = (
38
+ STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS +
39
+ %i[input extra]
40
+ ).freeze
41
+ private_constant :INITIALIZE_OPTIONS
42
+
30
43
  # @!attribute [rw] model
31
44
  # @return [String, nil] the model identifier
32
45
  # @!attribute [rw] instructions
@@ -145,14 +158,22 @@ module PromptBuilder
145
158
  # Create a new Session with the given options.
146
159
  # Accepts keyword arguments for all typed field groups (STRING_FIELDS,
147
160
  # FLOAT_FIELDS, INTEGER_FIELDS, BOOLEAN_FIELDS, JSONIFY_FIELDS); all default
148
- # to +nil+. The +input+ shorthand auto-creates a user message if provided.
161
+ # to +nil+. The +input+ (or +user+) shorthand auto-creates a user message
162
+ # if provided. Unsupported keyword options raise an ArgumentError.
149
163
  #
150
164
  # @param attributes [Hash] keyword options; see attribute declarations above
151
165
  # @option attributes [String, nil] :input optional string shorthand; a user
152
166
  # message is automatically added with this text
153
167
  # @option attributes [Hash, nil] :extra provider-specific extra data for
154
168
  # serializers; recognized keys vary by target format
169
+ # @raise [ArgumentError] if an unsupported option is passed or aliased
170
+ # options are passed together
155
171
  def initialize(**attributes)
172
+ unsupported = attributes.keys - INITIALIZE_OPTIONS
173
+ unless unsupported.empty?
174
+ raise ArgumentError, "unsupported option#{"s" if unsupported.size > 1}: #{unsupported.join(", ")}"
175
+ end
176
+
156
177
  (STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS).each do |f|
157
178
  send(:"#{f}=", attributes[f])
158
179
  end
@@ -286,6 +307,118 @@ module PromptBuilder
286
307
  end
287
308
  end
288
309
 
310
+ # Copy tool definitions from a ToolRegistry onto this session by name.
311
+ # With no names, all tools in the registry are copied (same as
312
+ # +register_tools+). Definitions are copied, so later registry changes do
313
+ # not affect the session and the tools survive +to_h+/+from_h+ round-trips.
314
+ #
315
+ # @param names [Array<String, Symbol>] the tool names to copy; empty for all
316
+ # @param registry [ToolRegistry, nil] the registry to copy from; defaults
317
+ # to the global +PromptBuilder.tool_registry+
318
+ # @return [Array<Tools::Definition>] the definitions registered on the session
319
+ # @raise [ToolNotFoundError] if a name is not registered in the registry
320
+ # @example
321
+ # session.use_tools("weather", "traffic_conditions")
322
+ # session.use_tools # all registry tools
323
+ # session.use_tools(:weather, registry: my_registry)
324
+ def use_tools(*names, registry: nil)
325
+ registry ||= PromptBuilder.tool_registry
326
+ raise ArgumentError, "registry must be an instance of ToolRegistry" unless registry.is_a?(ToolRegistry)
327
+
328
+ definitions = if names.empty?
329
+ registry.definitions
330
+ else
331
+ names.map do |name|
332
+ registry.definition_for(name.to_s) ||
333
+ raise(ToolNotFoundError, "No tool registered with name: #{name.to_s.inspect}")
334
+ end
335
+ end
336
+
337
+ definitions.map do |defn|
338
+ extra = defn.extra.transform_keys(&:to_sym)
339
+ register_tool(
340
+ defn.name,
341
+ description: defn.description,
342
+ parameters: defn.parameters,
343
+ strict: defn.strict,
344
+ **extra
345
+ )
346
+ end
347
+ end
348
+
349
+ # Configure JSON Schema structured output. Writes the canonical
350
+ # +text.format+ wire hash consumed by all serializers, preserving any
351
+ # other +text+ keys (e.g. +verbosity+) already set.
352
+ #
353
+ # @param schema [Hash] the JSON Schema for the response
354
+ # @param name [String] the schema name
355
+ # @param strict [Boolean, nil] whether strict schema adherence is requested;
356
+ # omitted from the format when nil
357
+ # @param description [String, nil] an optional schema description
358
+ # @return [Hash] the resulting +text+ configuration
359
+ # @example
360
+ # session.json_output({"type" => "object", "properties" => {...}}, strict: true)
361
+ def json_output(schema, name: "response", strict: nil, description: nil)
362
+ format = {"type" => "json_schema", "name" => name.to_s, "schema" => schema}
363
+ format["strict"] = strict unless strict.nil?
364
+ format["description"] = description.to_s if description
365
+ self.text = (text || {}).merge("format" => format)
366
+ end
367
+
368
+ # Configure reasoning/extended thinking portably across serializers.
369
+ # Stores a normalized +reasoning+ configuration; each serializer maps it
370
+ # to its native parameter:
371
+ #
372
+ # - +effort+ — Messages (+output_config.effort+), Chat Completions
373
+ # (+reasoning_effort+), Gemini (+thinkingConfig.thinkingLevel+), and
374
+ # Open Responses (+reasoning.effort+)
375
+ # - +budget_tokens+ — Messages (+thinking.budget_tokens+) and Gemini
376
+ # (+thinkingConfig.thinkingBudget+); ignored by effort-based APIs
377
+ # - Converse supports neither and warns once at serialization time
378
+ #
379
+ # Pass either +effort+ or +budget_tokens+, not both — providers reject
380
+ # requests that set both controls. Direct +session.reasoning = {...}+
381
+ # assignment keeps working for provider-specific keys.
382
+ #
383
+ # @param enabled [Boolean] pass +false+ to clear the reasoning configuration
384
+ # @param effort [String, Symbol, nil] a portable effort level; one of
385
+ # +minimal+, +low+, +medium+, +high+, +xhigh+, +max+ (unsupported levels
386
+ # are omitted by serializers whose target API does not accept them)
387
+ # @param budget_tokens [Integer, nil] an explicit thinking token budget
388
+ # @return [Hash, nil] the resulting +reasoning+ configuration
389
+ # @raise [ArgumentError] if neither or both of +effort+ and +budget_tokens+
390
+ # are given when enabling, or the values are invalid
391
+ # @example
392
+ # session.think(effort: :medium) # portable across serializers
393
+ # session.think(budget_tokens: 8_000) # explicit where supported
394
+ # session.think(false) # disable
395
+ def think(enabled = true, effort: nil, budget_tokens: nil)
396
+ unless enabled
397
+ raise ArgumentError, "cannot combine think(false) with effort or budget_tokens" if effort || budget_tokens
398
+
399
+ return self.reasoning = nil
400
+ end
401
+
402
+ if effort && budget_tokens
403
+ raise ArgumentError, "pass either effort or budget_tokens, not both"
404
+ elsif effort.nil? && budget_tokens.nil?
405
+ raise ArgumentError, "think requires effort: or budget_tokens:"
406
+ end
407
+
408
+ if effort
409
+ effort = effort.to_s
410
+ unless THINK_EFFORT_LEVELS.include?(effort)
411
+ raise ArgumentError, "effort must be one of: #{THINK_EFFORT_LEVELS.join(", ")}"
412
+ end
413
+ self.reasoning = {"effort" => effort}
414
+ else
415
+ budget_tokens = Integer(budget_tokens)
416
+ raise ArgumentError, "budget_tokens must be positive" unless budget_tokens.positive?
417
+
418
+ self.reasoning = {"budget_tokens" => budget_tokens}
419
+ end
420
+ end
421
+
289
422
  # Return all tool definitions registered on this session.
290
423
  #
291
424
  # @return [Array<Tools::Definition>] all tool definitions
metadata CHANGED
@@ -1,7 +1,7 @@
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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand