prompt_builder 0.2.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9eee723a908587c28aa7e606b0e6dad45e5958a9d895d29bd42f8d984153d6bc
4
- data.tar.gz: ffbfa13cc4bdfacc09ace7fe99a46366847faa501db6710cd51cca7123c106b2
3
+ metadata.gz: a6a857e2a0958425d04263986bca11967fb575af1c1e40ada5bee63556447fe0
4
+ data.tar.gz: 9840a2750ac27e265aea1e25d9290ee3aecb56cd24889d5903b481891a2925db
5
5
  SHA512:
6
- metadata.gz: 40023bf88305a391c88b8a189e2810e27ad5af96b2e5bbf3697296eb9b4f63a360239eed8022c343198ddeff09573874988a33372b40b7c15080603a7588f093
7
- data.tar.gz: b9be150ab138503f29f7769f71d1ad8092cef42aa5ba04ecb4d156f070fa5dc9aecdc1995a31fabfa74ab93206d47c1a24428417e2055d999c4473669c335f59
6
+ metadata.gz: 70fe6418fdd6865e12ec8babb5d5a7542ff31e93b44e0dfb389c1e0853955d5657bbf3482462e1ec091fca8031ac988bfc70222195a8ad2fd50833eb2542c4a1
7
+ data.tar.gz: bd0d9abb2cbb42d10d70c8f1d15322cc58a4c88c6401f937a45844fbde2dcf8013b183cf19d206b1e4c0e48cf9c0a03fb3ad5b35c17ed083b12d7c73f00224c6
data/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ 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.3.0
8
+
9
+ ### Added
10
+
11
+ - Response parsers now recognize API error payloads (OpenAI/Gemini `error` envelopes, Anthropic `type: "error"` responses, Bedrock exception bodies) and raise a `PromptBuilder::ErrorResponseError` (a subclass of `UnexpectedPayloadError`) containing the error message reported by the API.
12
+ - `Session.new` accepts a `system:` shorthand that adds a system message, mirroring the existing `input:` shorthand.
13
+ - `Session#clear` resets the conversation — items, `instructions`, and `previous_response_id` — while preserving model configuration and registered tools.
14
+ - `Session#remove_tool` removes a registered tool by name (string or symbol); `Session#clear_tools` removes them all. Both return the removed definitions.
15
+ - `Session#extra=` sets or clears provider-specific extra data after the session has been constructed. Keys are normalized to strings; assigning `nil` clears the data.
16
+ - Message content hashes with a `text` key no longer require a `type` key; the type defaults to `input_text` (or `output_text` on assistant messages).
17
+ - `Items::Message#system?`, `#user?`, and `#assistant?` role predicates.
18
+ - The Converse serializer emits `cachePoint` markers from the `cache_point` extra on tool definitions and `FunctionCallOutput` items, in addition to content blocks.
19
+ - `Session#to_h`/`Session.from_h` now round-trip the response boundary (`response_boundary_index`), and a `response_boundary_index=` setter is available for reconstructing session state.
20
+ - `Session::INITIALIZE_OPTIONS` is now public API: the frozen Array of all keyword options accepted by the constructor, for code that needs to validate or partition an options hash before creating a session.
21
+
22
+ ### Changed
23
+
24
+ - `Session#extra` always returns a Hash (previously `nil` when unset). The returned Hash is a copy; assign a modified copy back through `Session#extra=` to change it.
25
+ - `Session#to_h` omits `extra` when it is empty.
26
+ - `instructions` is now serialized after system/developer messages instead of before them, matching the Open Responses semantics of `instructions` applying to the current request.
27
+
28
+ ### Fixed
29
+
30
+ - Serializers silently dropped `Content::Text` content (hashes with `type: "text"`) from messages, system prompts, and tool results; it is now serialized as text everywhere `InputText`/`OutputText` are, including cache markers from content extras. The Messages and Gemini serializers also dropped `OutputText` content from system messages.
31
+ - Tool names are normalized to strings on registration, so registering the same tool by symbol and by string no longer creates duplicate definitions.
32
+
7
33
  ## 0.2.0
8
34
 
9
35
  ### Added
data/README.md CHANGED
@@ -48,16 +48,28 @@ session = PromptBuilder::Session.new(
48
48
  session.user("What is the capital of France?")
49
49
  ```
50
50
 
51
- You can also pass an `input` shorthand to create a user message in one step:
51
+ You can also pass `system` and `input` shorthands to create a system message and a user message in one step (equivalent to calling `session.system(...)` and `session.user(...)`):
52
52
 
53
53
  ```ruby
54
54
  session = PromptBuilder::Session.new(
55
55
  model: "gpt-5.4",
56
+ system: "You are a helpful assistant.",
56
57
  input: "What is the capital of France?"
57
58
  )
58
59
  ```
59
60
 
60
- Passing an option the constructor doesn't recognize (or both halves of an alias pair) raises an `ArgumentError`.
61
+ Passing an option the constructor doesn't recognize raises an `ArgumentError`. The full list of supported constructor options is available as `PromptBuilder::Session::INITIALIZE_OPTIONS`.
62
+
63
+ #### `instructions` vs. system and developer messages
64
+
65
+ Although both can be used to steer the model's behavior, `instructions` and system/developer messages are not interchangeable in the Open Responses API:
66
+
67
+ - `instructions` is a top-level request parameter, not part of the conversation. It is sent with every request, and in the Open Responses API it applies only to the request it accompanies — it is *not* carried over when chaining responses with `previous_response_id`. That makes it safe to change `session.instructions` at any point in a conversation; the new value simply applies from the next request onward.
68
+ - `session.system(...)` and `session.developer(...)` create messages inside the conversation history (the `input` array). They occupy a position in the transcript like any other message, and once the session is chained with `previous_response_id` they become part of the server-stored history and are not re-sent on subsequent requests.
69
+
70
+ As a rule of thumb, use `instructions` for standing behavior you may want to adjust from request to request, and use system or developer messages when the directive should be a durable part of the conversation record.
71
+
72
+ When serializing to the other formats this distinction collapses: `instructions` and any system/developer messages are merged together into the target format's single system field (see [Serializer Compatibility](#serializer-compatibility)).
61
73
 
62
74
  ### Conversation History
63
75
 
@@ -71,7 +83,13 @@ session.assistant("Hi there! How can I help you today?")
71
83
  session.user("What's the weather like?")
72
84
  ```
73
85
 
74
- Messages support the roles `user`, `assistant`, `system`, and `developer`.
86
+ Messages support the roles `user`, `assistant`, `system`, and `developer`. A `Message` exposes `system?`, `user?`, and `assistant?` predicates for checking its role.
87
+
88
+ Call `session.clear` to reset the conversation: it removes all items and the `instructions`, drops any `previous_response_id`, and returns the session to a fresh local-state start. Model configuration and registered tools are preserved, so the session can be reused for a new conversation.
89
+
90
+ ```ruby
91
+ session.clear # items and instructions removed; model/config/tools kept
92
+ ```
75
93
 
76
94
  ### Serializing Requests
77
95
 
@@ -162,6 +180,13 @@ response.has_tool_calls? # => false
162
180
  response.usage # => #<PromptBuilder::Usage input_tokens=25 output_tokens=12 ...>
163
181
  ```
164
182
 
183
+ If the payload is an error returned by the API rather than a completion (e.g. an authentication failure, a validation error, or an AWS Coral service envelope), `Response.parse` raises a `PromptBuilder::ErrorResponseError` whose message contains the error reported by the API. Payloads that are neither a completion nor a recognizable error envelope raise a `PromptBuilder::UnexpectedPayloadError` (which `ErrorResponseError` subclasses) including the offending body.
184
+
185
+ ```ruby
186
+ PromptBuilder::Response.parse({"error" => {"type" => "invalid_request_error", "message" => "Incorrect API key provided"}}, :chat_completion)
187
+ # => raises PromptBuilder::ErrorResponseError: the API returned an error: invalid_request_error: Incorrect API key provided
188
+ ```
189
+
165
190
  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
191
 
167
192
  ```ruby
@@ -298,6 +323,13 @@ session.use_tools(:weather, registry: my_registry) # explicit registry
298
323
 
299
324
  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
325
 
326
+ Remove tools from a session with `remove_tool` (by name; accepts a string or symbol) or `clear_tools` (all at once). `remove_tool` returns the removed `Tools::Definition`, or `nil` if no tool by that name was registered; `clear_tools` returns the removed definitions.
327
+
328
+ ```ruby
329
+ session.remove_tool("weather") # => removed Tools::Definition, or nil
330
+ session.clear_tools # => [<removed definitions>]
331
+ ```
332
+
301
333
  ### Content Types
302
334
 
303
335
  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.
@@ -418,6 +450,8 @@ session = PromptBuilder::Session.new(
418
450
 
419
451
  The `extra` attribute on sessions, content blocks, and tool definitions lets you pass provider-specific parameters that are not part of the Open Responses canonical format. Each serializer recognizes a defined set of `extra` keys and maps them to the appropriate location in the target format. Unrecognized keys are silently ignored.
420
452
 
453
+ On a session, `extra` is a read/write attribute that can be set at any point in the session's life. On content blocks and tool definitions it is set with keyword arguments at construction time.
454
+
421
455
  #### Session Extra
422
456
 
423
457
  Pass provider-specific top-level request parameters via `session.extra`:
@@ -471,6 +505,26 @@ session = PromptBuilder::Session.new(
471
505
  )
472
506
  ```
473
507
 
508
+ The same data can be set after the session has been created by assigning to `session.extra`. Keys are normalized to strings, so symbol keys work too.
509
+
510
+ ```ruby
511
+ session = PromptBuilder::Session.new(model: "anthropic.claude-v2")
512
+
513
+ session.extra # => {}
514
+
515
+ # Assign the whole hash.
516
+ session.extra = {guardrail_config: {"guardrailIdentifier" => "my-guardrail", "guardrailVersion" => "1"}}
517
+
518
+ # `extra` returns a copy, so read, modify, and assign it back to change one key.
519
+ session.extra = session.extra.merge("stop_sequences" => ["\n\nHuman:"])
520
+ session.extra = session.extra.except("stop_sequences")
521
+
522
+ session.extra = nil # clears it
523
+ ```
524
+
525
+ > [!IMPORTANT]
526
+ > `session.extra` never returns `nil` and its keys are always strings. Because the reader returns a copy, mutating it in place has no effect on the session — `session.extra["seed"] = 42` is silently discarded. Assign the modified hash back as shown above.
527
+
474
528
  #### Content Extra
475
529
 
476
530
  Content blocks support provider-specific attributes via keyword arguments that are captured in the `extra` hash:
@@ -500,6 +554,21 @@ session.user([
500
554
  cache_point: true
501
555
  )
502
556
  ])
557
+
558
+ # Bedrock Converse: cache_point on tool definitions and tool results
559
+ session.register_tool(
560
+ "search",
561
+ description: "Search the knowledge base.",
562
+ parameters: {"type" => "object", "properties" => {"query" => {"type" => "string"}}},
563
+ cache_point: true
564
+ )
565
+ session.add_item(
566
+ PromptBuilder::Items::FunctionCallOutput.new(
567
+ call_id: "call_1",
568
+ output: "Search results...",
569
+ cache_point: true
570
+ )
571
+ )
503
572
  ```
504
573
 
505
574
  #### Tool Definition Extra
@@ -518,12 +587,14 @@ session.register_tool(
518
587
 
519
588
  #### Recognized Extra Keys by Serializer
520
589
 
590
+ Session extra keys can be supplied to the constructor or assigned later with `session.extra=`.
591
+
521
592
  | Serializer | Session Extra Keys | Content Extra Keys | Tool Extra Keys |
522
593
  |:---|:---|:---|:---|
523
594
  | **Chat Completions** | `stop`, `seed`, `logit_bias`, `n`, `prediction`, `web_search_options`, `modalities`, `audio` | `file_id`, `media_type` | — |
524
595
  | **Messages** | `top_k`, `stop_sequences`, `cache_control` | `cache_control`, `citations`, `file_id`, `media_type` | `cache_control` |
525
596
  | **Gemini** | `safety_settings`, `cached_content`, `stop_sequences`, `top_k`, `seed`, `candidate_count`, `response_modalities`, `media_resolution` | `file_id`, `media_type`, `thought_signature` | — |
526
- | **Converse** | `stop_sequences`, `guardrail_config`, `additional_model_request_fields`, `additional_model_response_field_paths`, `performance_config`, `prompt_variables` | `media_type`, `cache_point` | |
597
+ | **Converse** | `stop_sequences`, `guardrail_config`, `additional_model_request_fields`, `additional_model_response_field_paths`, `performance_config`, `prompt_variables` | `media_type`, `cache_point` | `cache_point` |
527
598
 
528
599
  ### Serialization and Persistence
529
600
 
@@ -794,7 +865,7 @@ Content and message restrictions:
794
865
  - `FunctionCall.arguments` must parse to a JSON object; non-object JSON values raise (Bedrock's `toolUse.input` requires an object).
795
866
  - `FunctionCallOutput.output` content supports `InputText`/`OutputText`, `InputImage`, `InputFile`, and `InputVideo`, mapping to Converse `text`, `image`, `document`, and `video` tool result blocks. Converse also supports `json` and `searchResult` tool result blocks, but this gem has no canonical content types for them, so unsupported content is omitted. `FunctionCallOutput.status` is mapped: `completed` → `success`, `failed`/`incomplete` → `error`, anything else passes through or is dropped.
796
867
  - `tool_choice: "none"` and `tool_choice` without registered tools are both omitted.
797
- - Converse API request features with no canonical Open Responses field are available through `session.extra` (`stop_sequences`, `guardrail_config`, `additional_model_request_fields`, `additional_model_response_field_paths`, `performance_config`, `prompt_variables`) and content `extra` (`cache_point`). Features not exposed at all include `guardContent`, audio blocks, and `searchResult` blocks.
868
+ - Converse API request features with no canonical Open Responses field are available through `session.extra` (`stop_sequences`, `guardrail_config`, `additional_model_request_fields`, `additional_model_response_field_paths`, `performance_config`, `prompt_variables`) and content `extra` (`cache_point`). `cache_point` is also recognized on tool definitions (the marker is appended to the `toolConfig.tools` array) and on `FunctionCallOutput` items (the marker is appended after the `toolResult` content block). Features not exposed at all include `guardContent`, audio blocks, and `searchResult` blocks.
798
869
  - The serialized payload includes `modelId` as a convenience; the Converse REST endpoint takes the model id in the URL path (`/model/{modelId}/converse`), and AWS REST-JSON services ignore unrecognized body members. Remove it from the body if you prefer a strictly minimal payload.
799
870
 
800
871
  Response-side limitations:
@@ -802,7 +873,7 @@ Response-side limitations:
802
873
  - Unknown content block keys (e.g. `citationsContent`, `guardContent`) are silently skipped.
803
874
  - `metrics.latencyMs`, `trace` (guardrail and prompt-router trace events), `additionalModelResponseFields`, `performanceConfig`, and the raw `serviceTier` object are exposed on `response.provider_data`. `response.service_tier` is also populated from `serviceTier.type`.
804
875
  - `stopReason` mappings: `end_turn` / `tool_use` / `stop_sequence` → `completed`, `max_tokens` / `model_context_window_exceeded` → `incomplete`, `guardrail_intervened` / `content_filtered` / `malformed_model_output` / `malformed_tool_use` → `failed`. Unlike the Messages serializer, the matched stop sequence text is not surfaced separately because Converse does not echo it back unless you request provider-specific fields through `additionalModelResponseFieldPaths` (available via the `additional_model_response_field_paths` session extra; the requested fields appear on `response.provider_data`).
805
- - `usage.cacheReadInputTokens` and `usage.cacheWriteInputTokens` populate `response.usage.input_tokens_details["cached_tokens"]` and `["cache_creation_input_tokens"]`. Cache writes require `cachePoint` markers in the request, which are emitted via the `cache_point` content extra.
876
+ - `usage.cacheReadInputTokens` and `usage.cacheWriteInputTokens` populate `response.usage.input_tokens_details["cached_tokens"]` and `["cache_creation_input_tokens"]`. Cache writes require `cachePoint` markers in the request, which are emitted via the `cache_point` extra on content, tool definitions, and `FunctionCallOutput` items.
806
877
 
807
878
  ## Installation
808
879
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0
1
+ 0.3.0
@@ -19,6 +19,11 @@ module PromptBuilder
19
19
  # Raised when a response payload does not match the expected shape.
20
20
  class UnexpectedPayloadError < Error; end
21
21
 
22
+ # Raised when a response payload is an error returned by the API rather
23
+ # than a completion (e.g. an authentication, validation, or throttling
24
+ # error envelope). The message contains the error reported by the API.
25
+ class ErrorResponseError < UnexpectedPayloadError; end
26
+
22
27
  # Raised by Response#parsed_json! when the response text is not valid JSON.
23
28
  class ParseError < Error; end
24
29
  end
@@ -23,6 +23,24 @@ module PromptBuilder
23
23
  # @return [Hash, nil] provider-specific extra data
24
24
  attr_reader :extra
25
25
 
26
+ class << self
27
+ # Deserialize a Message from a Hash.
28
+ #
29
+ # @param hash [Hash] a Hash with string keys
30
+ # @return [Message]
31
+ def from_h(hash)
32
+ content = (hash["content"] || []).map { |c| Content::Base.from_h(c) }
33
+ new(
34
+ id: hash["id"],
35
+ role: hash["role"],
36
+ status: hash["status"],
37
+ phase: hash["phase"],
38
+ content: content,
39
+ **hash.except("type", "id", "role", "status", "phase", "content").transform_keys(&:to_sym)
40
+ )
41
+ end
42
+ end
43
+
26
44
  # Create a new Message item.
27
45
  #
28
46
  # @param id [String, nil] the message identifier
@@ -40,22 +58,25 @@ module PromptBuilder
40
58
  @extra = extra.transform_keys(&:to_s)
41
59
  end
42
60
 
43
- class << self
44
- # Deserialize a Message from a Hash.
45
- #
46
- # @param hash [Hash] a Hash with string keys
47
- # @return [Message]
48
- def from_h(hash)
49
- content = (hash["content"] || []).map { |c| Content::Base.from_h(c) }
50
- new(
51
- id: hash["id"],
52
- role: hash["role"],
53
- status: hash["status"],
54
- phase: hash["phase"],
55
- content: content,
56
- **hash.except("type", "id", "role", "status", "phase", "content").transform_keys(&:to_sym)
57
- )
58
- end
61
+ # Check if the message is a system message.
62
+ #
63
+ # @return [Boolean]
64
+ def system?
65
+ role == "system"
66
+ end
67
+
68
+ # Check if the message is a user message.
69
+ #
70
+ # @return [Boolean]
71
+ def user?
72
+ role == "user"
73
+ end
74
+
75
+ # Check if the message is an assistant message.
76
+ #
77
+ # @return [Boolean]
78
+ def assistant?
79
+ role == "assistant"
59
80
  end
60
81
 
61
82
  # Serialize to a Hash with string keys.
@@ -102,6 +123,9 @@ module PromptBuilder
102
123
 
103
124
  def normalize_hash_content(hash)
104
125
  hash = hash.transform_keys(&:to_s)
126
+ if hash["type"].nil? && hash.key?("text")
127
+ hash = hash.merge("type" => (assistant? ? "output_text" : "input_text"))
128
+ end
105
129
  unless hash["type"]
106
130
  raise InvalidItemError,
107
131
  "Content hash is missing required \"type\" key: #{hash.inspect}"
@@ -18,7 +18,9 @@ module PromptBuilder
18
18
  #
19
19
  # @param hash [Hash] the response hash in the target format
20
20
  # @return [Response] the parsed response
21
+ # @raise [ErrorResponseError] if the payload is an API error envelope
21
22
  def parse_response(hash)
23
+ check_error_response!(hash)
22
24
  deserialize_response(hash)
23
25
  end
24
26
 
@@ -32,6 +34,32 @@ module PromptBuilder
32
34
  raise NotImplementedError
33
35
  end
34
36
 
37
+ # Detect API error envelopes before parsing so errors surface with the
38
+ # message reported by the API instead of the generic missing-key error
39
+ # from require_response_key!. Serializer response classes override
40
+ # error_response_message to recognize their format's error envelope.
41
+ #
42
+ # @param hash [Object] the raw response payload
43
+ # @return [void]
44
+ # @raise [ErrorResponseError] if the payload is an API error envelope
45
+ def check_error_response!(hash)
46
+ return unless hash.is_a?(Hash)
47
+
48
+ message = error_response_message(hash)
49
+ return if message.nil? || message.empty?
50
+
51
+ raise ErrorResponseError, "the API returned an error: #{message}"
52
+ end
53
+
54
+ # Extract a human-readable message from an API error envelope.
55
+ #
56
+ # @param _hash [Hash] the raw response payload
57
+ # @return [String, nil] nil when the payload is not recognized as an
58
+ # error envelope
59
+ def error_response_message(_hash)
60
+ nil
61
+ end
62
+
35
63
  # Ensure a response payload is a Hash containing the key that identifies
36
64
  # it as a well-formed response for this format. Raised before parsing so
37
65
  # malformed bodies (e.g. provider error envelopes) fail loudly with the
@@ -27,6 +27,10 @@ module PromptBuilder
27
27
  # supported, and only when +stream+ is set (otherwise it is omitted)
28
28
  #
29
29
  # Input content restrictions:
30
+ # - +instructions+ is serialized as a system message inserted after the
31
+ # last system/developer message, or first when there are none
32
+ # (instructions apply to the current request, matching Open Responses
33
+ # semantics)
30
34
  # - +InputVideo+ content is not supported in any message (omitted)
31
35
  # - +Reasoning+ items are not supported (skipped)
32
36
  # - +RefusalContent+ is dropped silently (a parsed Chat Completions
@@ -119,7 +123,8 @@ module PromptBuilder
119
123
  end
120
124
 
121
125
  # Session extra: recognized keys for Chat Completions API
122
- apply_session_extra!(h, session.extra) if session.extra
126
+ extra = session.extra
127
+ apply_session_extra!(h, extra) unless extra.empty?
123
128
 
124
129
  h
125
130
  end
@@ -138,10 +143,6 @@ module PromptBuilder
138
143
  def build_messages(session)
139
144
  messages = []
140
145
 
141
- if session.instructions
142
- messages << {"role" => "system", "content" => session.instructions}
143
- end
144
-
145
146
  pending_tool_calls = []
146
147
  last_assistant_msg = nil
147
148
 
@@ -171,9 +172,18 @@ module PromptBuilder
171
172
  end
172
173
 
173
174
  flush_tool_calls!(messages, pending_tool_calls, last_assistant_msg)
175
+ insert_instructions!(messages, session.instructions) if session.instructions
174
176
  messages
175
177
  end
176
178
 
179
+ # Instructions apply to the current request (matching Open Responses
180
+ # semantics), so they are inserted after the last system/developer
181
+ # message rather than before any of them.
182
+ def insert_instructions!(messages, instructions)
183
+ index = messages.rindex { |msg| msg["role"] == "system" || msg["role"] == "developer" }
184
+ messages.insert(index ? index + 1 : 0, {"role" => "system", "content" => instructions})
185
+ end
186
+
177
187
  def serialize_message(item)
178
188
  # Messages with an unsupported role are silently skipped.
179
189
  return nil unless SUPPORTED_MESSAGE_ROLES.include?(item.role)
@@ -198,7 +208,7 @@ module PromptBuilder
198
208
 
199
209
  def serialize_content(role, content)
200
210
  case content
201
- when Content::InputText, Content::OutputText
211
+ when Content::InputText, Content::OutputText, Content::Text
202
212
  serialize_text_content(content)
203
213
  when Content::InputImage
204
214
  # Image content is only supported in user messages; omit otherwise.
@@ -241,7 +251,7 @@ module PromptBuilder
241
251
  # are silently omitted.
242
252
  content = output.filter_map do |content|
243
253
  case content
244
- when Content::InputText, Content::OutputText
254
+ when Content::InputText, Content::OutputText, Content::Text
245
255
  serialize_text_content(content)
246
256
  end
247
257
  end
@@ -16,6 +16,18 @@ module PromptBuilder
16
16
  class << self
17
17
  private
18
18
 
19
+ # OpenAI errors arrive as {"error" => {"message" => ..., "type" =>
20
+ # ..., "code" => ...}}; some compatible servers return a bare string
21
+ # under "error".
22
+ def error_response_message(hash)
23
+ error = hash["error"]
24
+ return nil if error.nil? || hash.key?("choices")
25
+
26
+ return error.to_s unless error.is_a?(Hash)
27
+
28
+ [error["code"] || error["type"], error["message"]].compact.join(": ")
29
+ end
30
+
19
31
  def deserialize_response(hash)
20
32
  validate_response!(hash)
21
33
 
@@ -39,6 +39,10 @@ module PromptBuilder
39
39
  # - +tool_choice: "none"+ has no Converse representation and is omitted
40
40
  #
41
41
  # Input content restrictions:
42
+ # - System and developer messages are hoisted out of conversational order
43
+ # into the top-level +system+ parameter, with +instructions+ merged last
44
+ # (instructions apply to the current request, matching Open Responses
45
+ # semantics)
42
46
  # - +Reasoning+ items are silently skipped
43
47
  # - +RefusalContent+ is dropped silently (a parsed response refusal can
44
48
  # stay in session history without breaking subsequent request_payload calls)
@@ -68,8 +72,11 @@ module PromptBuilder
68
72
  # - Prompt management variables (+prompt_variables+)
69
73
  #
70
74
  # Prompt caching markers (+cachePoint+) are emitted via the +cache_point+
71
- # content extra. Cross-region routing via inference profiles is selected
72
- # through the model id and needs no request field.
75
+ # extra on system/message content, tool definitions (appended to the
76
+ # +toolConfig.tools+ array), and +FunctionCallOutput+ items (appended to
77
+ # the message content after the +toolResult+ block). Cross-region routing
78
+ # via inference profiles is selected through the model id and needs no
79
+ # request field.
73
80
  class Request < Base
74
81
  IMAGE_MEDIA_TYPE_FORMATS = {
75
82
  "image/jpeg" => "jpeg",
@@ -164,7 +171,8 @@ module PromptBuilder
164
171
  h["toolConfig"] = tool_config if tool_config
165
172
 
166
173
  # Session extra: recognized keys for Converse API
167
- apply_session_extra!(h, session.extra) if session.extra
174
+ extra = session.extra
175
+ apply_session_extra!(h, extra) unless extra.empty?
168
176
 
169
177
  h
170
178
  end
@@ -194,8 +202,6 @@ module PromptBuilder
194
202
  def build_system(session)
195
203
  parts = []
196
204
 
197
- parts << {"text" => session.instructions} if session.instructions
198
-
199
205
  session.items.each do |item|
200
206
  next unless item.is_a?(Items::Message)
201
207
  next unless item.role == "system" || item.role == "developer"
@@ -211,6 +217,11 @@ module PromptBuilder
211
217
  end
212
218
  end
213
219
 
220
+ # Instructions come last: in the Open Responses API they apply to
221
+ # the current request, so they must follow any system messages
222
+ # accumulated in the conversation history.
223
+ parts << {"text" => session.instructions} if session.instructions
224
+
214
225
  parts
215
226
  end
216
227
 
@@ -249,10 +260,14 @@ module PromptBuilder
249
260
  }]
250
261
  }
251
262
  when Items::FunctionCallOutput
252
- raw_messages << {
253
- "role" => "user",
254
- "content" => [serialize_tool_result(item, ctx)]
255
- }
263
+ # The Converse ToolResultContentBlock union has no cachePoint
264
+ # member; the marker must be a sibling content block after the
265
+ # toolResult block.
266
+ content = [serialize_tool_result(item, ctx)]
267
+ if item.extra && item.extra["cache_point"]
268
+ content << {"cachePoint" => {"type" => "default"}}
269
+ end
270
+ raw_messages << {"role" => "user", "content" => content}
256
271
  when Items::Reasoning, Items::Compaction, Items::ItemReference
257
272
  # Reasoning, Compaction, and ItemReference items are not supported
258
273
  # in the request, so ignore them rather than raising an error.
@@ -265,7 +280,7 @@ module PromptBuilder
265
280
 
266
281
  def serialize_system_content(content)
267
282
  case content
268
- when Content::InputText
283
+ when Content::InputText, Content::Text
269
284
  {"text" => content.text}
270
285
  when Content::OutputText
271
286
  validate_output_text!(content)
@@ -293,7 +308,7 @@ module PromptBuilder
293
308
 
294
309
  def serialize_content(content, role:, ctx: nil)
295
310
  case content
296
- when Content::InputText, Content::OutputText
311
+ when Content::InputText, Content::OutputText, Content::Text
297
312
  validate_output_text!(content) if content.is_a?(Content::OutputText)
298
313
  {"text" => content.text}
299
314
  when Content::InputImage
@@ -512,7 +527,7 @@ module PromptBuilder
512
527
 
513
528
  def serialize_tool_result_content(content, ctx)
514
529
  case content
515
- when Content::InputText, Content::OutputText
530
+ when Content::InputText, Content::OutputText, Content::Text
516
531
  validate_output_text!(content) if content.is_a?(Content::OutputText)
517
532
  {"text" => content.text}
518
533
  when Content::InputImage
@@ -610,13 +625,17 @@ module PromptBuilder
610
625
  end
611
626
 
612
627
  def build_tools(session)
613
- session.tool_definitions.map do |definition|
628
+ session.tool_definitions.flat_map do |definition|
614
629
  tool_spec = {"name" => definition.name}
615
630
  tool_spec["description"] = definition.description if definition.description
616
631
  tool_spec["inputSchema"] = {
617
632
  "json" => definition.parameters || {"type" => "object", "properties" => {}}
618
633
  }
619
- {"toolSpec" => tool_spec}
634
+ tools = [{"toolSpec" => tool_spec}]
635
+ # The Converse Tool union type has a cachePoint member, so the
636
+ # marker is its own entry in the tools array.
637
+ tools << {"cachePoint" => {"type" => "default"}} if definition.extra["cache_point"]
638
+ tools
620
639
  end
621
640
  end
622
641
 
@@ -10,6 +10,21 @@ module PromptBuilder
10
10
  class << self
11
11
  private
12
12
 
13
+ # Bedrock exception bodies carry a top-level "message" (the exception
14
+ # class travels in the x-amzn-ErrorType header) or a top-level
15
+ # "__type", while misrouted requests get a Coral service envelope
16
+ # like {"Output" => {"__type" => "..."}, "Version" => "1.0"}.
17
+ def error_response_message(hash)
18
+ return nil if hash.key?("output")
19
+
20
+ coral_output = hash["Output"].is_a?(Hash) ? hash["Output"] : {}
21
+ error_type = hash["__type"] || coral_output["__type"]
22
+ message = hash["message"] || hash["Message"] || coral_output["message"] || coral_output["Message"]
23
+ return nil unless error_type || message
24
+
25
+ [error_type, message].compact.join(": ")
26
+ end
27
+
13
28
  def deserialize_response(hash)
14
29
  require_response_key!(hash, "output")
15
30
  require_response_key!(hash, "stopReason")
@@ -27,6 +27,12 @@ module PromptBuilder
27
27
  # - +truncation+ — server-side context truncation is not supported
28
28
  #
29
29
  # Input content restrictions:
30
+ # - System and developer messages are hoisted out of conversational order
31
+ # into the top-level +systemInstruction+ field, with +instructions+ merged
32
+ # last (instructions apply to the current request, matching Open Responses
33
+ # semantics)
34
+ # - Only text content (+InputText+/+OutputText+/+Text+) survives in system
35
+ # and developer messages; other content is silently omitted
30
36
  # - +InputImage+ content is only supported in user messages (assistant images are omitted)
31
37
  # - +InputImage+ with +image_url+ requires either base64 +data+ or a URL;
32
38
  # content without +image_url+ or +file_id+ raises
@@ -136,7 +142,8 @@ module PromptBuilder
136
142
  h["toolConfig"] = tool_config if tool_config
137
143
 
138
144
  # Session extra: recognized keys for Gemini API
139
- apply_session_extra!(h, session.extra) if session.extra
145
+ extra = session.extra
146
+ apply_session_extra!(h, extra) unless extra.empty?
140
147
 
141
148
  # Gemini selects streaming via endpoint (:streamGenerateContent)
142
149
  # rather than a request body field, so session.stream is a no-op
@@ -162,19 +169,24 @@ module PromptBuilder
162
169
  def build_system_instruction(session)
163
170
  parts = []
164
171
 
165
- if session.instructions
166
- parts << {"text" => session.instructions}
167
- end
168
-
169
172
  session.items.each do |item|
170
173
  next unless item.is_a?(Items::Message)
171
174
  next unless item.role == "system" || item.role == "developer"
172
175
 
173
176
  item.content.each do |content|
174
- parts << {"text" => content.text} if content.is_a?(Content::InputText)
177
+ if content.is_a?(Content::InputText) || content.is_a?(Content::OutputText) || content.is_a?(Content::Text)
178
+ parts << {"text" => content.text}
179
+ end
175
180
  end
176
181
  end
177
182
 
183
+ # Instructions come last: in the Open Responses API they apply to
184
+ # the current request, so they must follow any system messages
185
+ # accumulated in the conversation history.
186
+ if session.instructions
187
+ parts << {"text" => session.instructions}
188
+ end
189
+
178
190
  return nil if parts.empty?
179
191
 
180
192
  {"parts" => parts}
@@ -274,7 +286,7 @@ module PromptBuilder
274
286
  # types are silently omitted.
275
287
  text = output.filter_map do |content|
276
288
  case content
277
- when Content::InputText, Content::OutputText
289
+ when Content::InputText, Content::OutputText, Content::Text
278
290
  content.text
279
291
  end
280
292
  end.join("\n")
@@ -307,7 +319,7 @@ module PromptBuilder
307
319
 
308
320
  def serialize_content(content, role:)
309
321
  case content
310
- when Content::InputText, Content::OutputText
322
+ when Content::InputText, Content::OutputText, Content::Text
311
323
  part = {"text" => content.text}
312
324
  if content.extra && content.extra["thought_signature"]
313
325
  part["thoughtSignature"] = content.extra["thought_signature"]
@@ -63,6 +63,15 @@ module PromptBuilder
63
63
  class << self
64
64
  private
65
65
 
66
+ # Gemini errors arrive as
67
+ # {"error" => {"code" => 400, "message" => ..., "status" => ...}}.
68
+ def error_response_message(hash)
69
+ error = hash["error"]
70
+ return nil unless error.is_a?(Hash) && !hash.key?("candidates")
71
+
72
+ [error["status"] || error["code"], error["message"]].compact.join(": ")
73
+ end
74
+
66
75
  def deserialize_response(hash)
67
76
  require_response_key!(hash, "candidates")
68
77
  require_response_key!(hash, "modelVersion")
@@ -36,6 +36,12 @@ module PromptBuilder
36
36
  # is enabled
37
37
  #
38
38
  # Input content restrictions:
39
+ # - System and developer messages are hoisted out of conversational order
40
+ # into the top-level +system+ parameter, with +instructions+ merged last
41
+ # (instructions apply to the current request, matching Open Responses
42
+ # semantics)
43
+ # - Only text content (+InputText+/+OutputText+/+Text+) survives in system
44
+ # and developer messages; other content is silently omitted
39
45
  # - +InputVideo+ content is not supported and is omitted
40
46
  # - +RefusalContent+ is dropped silently (a parsed refusal can stay in
41
47
  # session history without breaking subsequent request_payload calls)
@@ -115,7 +121,8 @@ module PromptBuilder
115
121
  h["stream"] = session.stream unless session.stream.nil?
116
122
 
117
123
  # Session extra: recognized keys for Messages API
118
- apply_session_extra!(h, session.extra) if session.extra
124
+ extra = session.extra
125
+ apply_session_extra!(h, extra) unless extra.empty?
119
126
 
120
127
  output_config = build_output_config(session)
121
128
  h["output_config"] = output_config unless output_config.empty?
@@ -279,16 +286,12 @@ module PromptBuilder
279
286
  def build_system(session)
280
287
  parts = []
281
288
 
282
- if session.instructions
283
- parts << {"type" => "text", "text" => session.instructions}
284
- end
285
-
286
289
  session.items.each do |item|
287
290
  next unless item.is_a?(Items::Message)
288
291
  next unless item.role == "system" || item.role == "developer"
289
292
 
290
293
  item.content.each do |content|
291
- if content.is_a?(Content::InputText)
294
+ if content.is_a?(Content::InputText) || content.is_a?(Content::OutputText) || content.is_a?(Content::Text)
292
295
  part = {"type" => "text", "text" => content.text}
293
296
  if content.extra && content.extra["cache_control"]
294
297
  part["cache_control"] = content.extra["cache_control"]
@@ -298,6 +301,13 @@ module PromptBuilder
298
301
  end
299
302
  end
300
303
 
304
+ # Instructions come last: in the Open Responses API they apply to
305
+ # the current request, so they must follow any system messages
306
+ # accumulated in the conversation history.
307
+ if session.instructions
308
+ parts << {"type" => "text", "text" => session.instructions}
309
+ end
310
+
301
311
  parts
302
312
  end
303
313
 
@@ -363,7 +373,7 @@ module PromptBuilder
363
373
 
364
374
  def serialize_content_block(content, role:)
365
375
  case content
366
- when Content::InputText
376
+ when Content::InputText, Content::Text
367
377
  {"type" => "text", "text" => content.text}
368
378
  when Content::OutputText
369
379
  text = {"type" => "text", "text" => content.text}
@@ -492,7 +502,7 @@ module PromptBuilder
492
502
  # Document blocks are rejected by the API.
493
503
  def serialize_tool_result_content(content)
494
504
  case content
495
- when Content::InputText, Content::OutputText
505
+ when Content::InputText, Content::OutputText, Content::Text
496
506
  {"type" => "text", "text" => content.text}
497
507
  when Content::InputImage
498
508
  serialize_content(content, role: "user")
@@ -10,6 +10,15 @@ module PromptBuilder
10
10
  class << self
11
11
  private
12
12
 
13
+ # Anthropic errors arrive as
14
+ # {"type" => "error", "error" => {"type" => ..., "message" => ...}}.
15
+ def error_response_message(hash)
16
+ error = hash["error"]
17
+ return nil unless error.is_a?(Hash) && !hash.key?("content")
18
+
19
+ [error["type"], error["message"]].compact.join(": ")
20
+ end
21
+
13
22
  def deserialize_response(hash)
14
23
  require_response_key!(hash, "content")
15
24
  require_response_key!(hash, "stop_reason")
@@ -28,6 +28,7 @@ module PromptBuilder
28
28
  payload = session.to_h
29
29
  items = apply_server_state!(payload, session)
30
30
  payload.delete("extra")
31
+ payload.delete("response_boundary_index")
31
32
  strip_extra(payload, items, session.tool_definitions)
32
33
  normalize_content_urls!(payload)
33
34
  strip_non_replayable_reasoning!(payload)
@@ -8,6 +8,16 @@ module PromptBuilder
8
8
  class << self
9
9
  private
10
10
 
11
+ # Open Responses request errors arrive as a bare {"error" => {...}}
12
+ # envelope. A failed response object also carries an "error" field,
13
+ # but it has a "status" and parses into a failed Response instead.
14
+ def error_response_message(hash)
15
+ error = hash["error"]
16
+ return nil unless error.is_a?(Hash) && !hash.key?("status")
17
+
18
+ [error["code"] || error["type"], error["message"]].compact.join(": ")
19
+ end
20
+
11
21
  def deserialize_response(hash)
12
22
  require_response_key!(hash, "status")
13
23
  require_response_key!(hash, "object")
@@ -34,11 +34,17 @@ module PromptBuilder
34
34
  private_constant :THINK_EFFORT_LEVELS
35
35
 
36
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
37
45
  INITIALIZE_OPTIONS = (
38
- STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS +
39
- %i[input extra]
46
+ STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS + %i[input extra system]
40
47
  ).freeze
41
- private_constant :INITIALIZE_OPTIONS
42
48
 
43
49
  # @!attribute [rw] model
44
50
  # @return [String, nil] the model identifier
@@ -122,10 +128,44 @@ module PromptBuilder
122
128
  # @return [Integer] the index in +items+ marking the boundary after the last response
123
129
  attr_reader :response_boundary_index
124
130
 
125
- # @return [Hash, nil] provider-specific extra data for serializers.
126
- # Recognized keys vary by target format. Unrecognized keys are silently
127
- # ignored by each serializer.
128
- 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
129
169
 
130
170
  class << self
131
171
  # Deserialize a Session from a Hash produced by +to_h+ or parsed JSON.
@@ -138,7 +178,7 @@ module PromptBuilder
138
178
  def from_h(hash)
139
179
  attrs = (STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS)
140
180
  .each_with_object({}) { |f, acc| acc[f] = hash[f.to_s] }
141
- attrs[:extra] = hash["extra"] if hash["extra"]
181
+ attrs[:extra] = hash["extra"]
142
182
  session = new(**attrs)
143
183
 
144
184
  Array(hash["input"]).each do |item_hash|
@@ -151,6 +191,8 @@ module PromptBuilder
151
191
  session.register_tool(defn.name, description: defn.description, parameters: defn.parameters, strict: defn.strict, **extra)
152
192
  end
153
193
 
194
+ session.response_boundary_index = hash["response_boundary_index"] if hash["response_boundary_index"]
195
+
154
196
  session
155
197
  end
156
198
  end
@@ -158,16 +200,18 @@ module PromptBuilder
158
200
  # Create a new Session with the given options.
159
201
  # Accepts keyword arguments for all typed field groups (STRING_FIELDS,
160
202
  # FLOAT_FIELDS, INTEGER_FIELDS, BOOLEAN_FIELDS, JSONIFY_FIELDS); all default
161
- # to +nil+. The +input+ (or +user+) shorthand auto-creates a user message
162
- # if provided. Unsupported keyword options raise an ArgumentError.
203
+ # to +nil+. The +system+ and +input+ shorthands auto-create a system and
204
+ # user message if provided. Unsupported keyword options raise an ArgumentError.
163
205
  #
164
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
165
209
  # @option attributes [String, nil] :input optional string shorthand; a user
166
210
  # message is automatically added with this text
167
211
  # @option attributes [Hash, nil] :extra provider-specific extra data for
168
- # serializers; recognized keys vary by target format
169
- # @raise [ArgumentError] if an unsupported option is passed or aliased
170
- # options are passed together
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
171
215
  def initialize(**attributes)
172
216
  unsupported = attributes.keys - INITIALIZE_OPTIONS
173
217
  unless unsupported.empty?
@@ -177,8 +221,12 @@ module PromptBuilder
177
221
  (STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS).each do |f|
178
222
  send(:"#{f}=", attributes[f])
179
223
  end
180
- @extra = PromptBuilder.jsonify(attributes[:extra]) if attributes[:extra]
224
+
225
+ self.extra = attributes[:extra]
226
+
181
227
  @items = []
228
+ system(attributes[:system]) if attributes[:system]
229
+
182
230
  @tool_definitions = {}
183
231
  @response_boundary_index = 0
184
232
  user(attributes[:input]) if attributes[:input]
@@ -192,6 +240,7 @@ module PromptBuilder
192
240
  # session.user("Hello, how are you?")
193
241
  # session.user(Content::InputText.new(text: "Hello, how are you?"))
194
242
  # session.user(type: "input_text", text: "Hello, how are you?")
243
+ # session.user(text: "Hello, how are you?") # type defaults to "input_text"
195
244
  # session.user([
196
245
  # Content::InputText.new(text: "What is in this image?"),
197
246
  # Content::InputImage.new(url: "http://example.com/image.png")
@@ -216,6 +265,10 @@ module PromptBuilder
216
265
  #
217
266
  # @param content [String, Content::Base, Hash, Array<Content::Base>, Array<Hash>] the message content
218
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"})
219
272
  def system(content)
220
273
  add_item(Items::Message.new(role: "system", content: content))
221
274
  end
@@ -268,6 +321,19 @@ module PromptBuilder
268
321
  @response_boundary_index = @items.length
269
322
  end
270
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
+
271
337
  # Register a tool on this session.
272
338
  #
273
339
  # @param name [String] the tool name
@@ -284,7 +350,7 @@ module PromptBuilder
284
350
  strict: strict,
285
351
  **extra
286
352
  )
287
- @tool_definitions[name] = definition
353
+ @tool_definitions[name.to_s] = definition
288
354
  definition
289
355
  end
290
356
 
@@ -346,6 +412,31 @@ module PromptBuilder
346
412
  end
347
413
  end
348
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
+
349
440
  # Configure JSON Schema structured output. Writes the canonical
350
441
  # +text.format+ wire hash consumed by all serializers, preserving any
351
442
  # other +text+ keys (e.g. +verbosity+) already set.
@@ -465,6 +556,7 @@ module PromptBuilder
465
556
 
466
557
  h["input"] = @items.map(&:to_h) unless @items.empty?
467
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?
468
560
 
469
561
  h["tools"] = tool_definitions.map(&:to_h) unless @tool_definitions.empty?
470
562
 
@@ -488,7 +580,7 @@ module PromptBuilder
488
580
  val = send(f)
489
581
  h[f.to_s] = val if val
490
582
  }
491
- h["extra"] = @extra if @extra
583
+ h["extra"] = extra unless @extra.empty?
492
584
 
493
585
  h
494
586
  end
@@ -509,7 +601,7 @@ module PromptBuilder
509
601
  def config_hash
510
602
  h = (STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS - %i[previous_response_id])
511
603
  .each_with_object({}) { |f, acc| acc[f] = send(f) }
512
- h[:extra] = @extra if @extra
604
+ h[:extra] = @extra
513
605
  h
514
606
  end
515
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.2.0
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: []