prompt_builder 0.2.0 → 0.4.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: 81ca025b0770c1b045ba525bc7fb6839a25b2d0b760b06ba85698de84c0a36b7
4
+ data.tar.gz: e8fc9eded433a202f7b2c6e9c79d0a0f8a9325b156cc884d09e1f7af535679a0
5
5
  SHA512:
6
- metadata.gz: 40023bf88305a391c88b8a189e2810e27ad5af96b2e5bbf3697296eb9b4f63a360239eed8022c343198ddeff09573874988a33372b40b7c15080603a7588f093
7
- data.tar.gz: b9be150ab138503f29f7769f71d1ad8092cef42aa5ba04ecb4d156f070fa5dc9aecdc1995a31fabfa74ab93206d47c1a24428417e2055d999c4473669c335f59
6
+ metadata.gz: e5eab3ec44e9c2a958b60d6c8c24dd740c8586eef8e5d05b6d0ab67807bc5526c3946b8ddd50439d60a860fdcc96e7372d637c552d15938383ef36ecfa173486
7
+ data.tar.gz: b0aaf0e28017996c603101d8fbde94fc4c242ffa42d471caa7cb0bbaad55f812962873237cf839d51755a86d844ec4fe7676516d3ed8c87df322a6bcd33b424c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ 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.4.0
8
+
9
+ ### Added
10
+
11
+ - `Response.parse` (and the serializer `parse_response` methods) accept an optional `headers:` keyword with the HTTP response headers, for formats that return response metadata in headers rather than in the body. The Converse serializer uses it to populate `Response#id` from the `x-amzn-requestid` request metadata header, since the Converse API does not include a response id in the body. The header name lookup is case-insensitive.
12
+
13
+ ## 0.3.0
14
+
15
+ ### Added
16
+
17
+ - 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.
18
+ - `Session.new` accepts a `system:` shorthand that adds a system message, mirroring the existing `input:` shorthand.
19
+ - `Session#clear` resets the conversation — items, `instructions`, and `previous_response_id` — while preserving model configuration and registered tools.
20
+ - `Session#remove_tool` removes a registered tool by name (string or symbol); `Session#clear_tools` removes them all. Both return the removed definitions.
21
+ - `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.
22
+ - 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).
23
+ - `Items::Message#system?`, `#user?`, and `#assistant?` role predicates.
24
+ - The Converse serializer emits `cachePoint` markers from the `cache_point` extra on tool definitions and `FunctionCallOutput` items, in addition to content blocks.
25
+ - `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.
26
+ - `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.
27
+
28
+ ### Changed
29
+
30
+ - `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.
31
+ - `Session#to_h` omits `extra` when it is empty.
32
+ - `instructions` is now serialized after system/developer messages instead of before them, matching the Open Responses semantics of `instructions` applying to the current request.
33
+
34
+ ### Fixed
35
+
36
+ - 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.
37
+ - Tool names are normalized to strings on registration, so registering the same tool by symbol and by string no longer creates duplicate definitions.
38
+
7
39
  ## 0.2.0
8
40
 
9
41
  ### 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
 
@@ -153,6 +171,13 @@ You can also pass a serializer class directly:
153
171
  response = PromptBuilder::Response.parse(JSON.parse(response_body), PromptBuilder::Serializers::ChatCompletion)
154
172
  ```
155
173
 
174
+ `Response.parse` accepts an optional `headers:` keyword with the HTTP response headers, for formats that return response metadata in headers rather than in the body. The Converse API is one of them: Bedrock puts the response id in the `x-amzn-requestid` request metadata header, so pass the headers to populate `response.id` (the header name lookup is case-insensitive):
175
+
176
+ ```ruby
177
+ response = PromptBuilder::Response.parse(JSON.parse(response_body), :converse, headers: http_response_headers)
178
+ response.id # => "6e1d5a7b-..."
179
+ ```
180
+
156
181
  The `Response` object provides convenient accessors:
157
182
 
158
183
  ```ruby
@@ -162,6 +187,13 @@ response.has_tool_calls? # => false
162
187
  response.usage # => #<PromptBuilder::Usage input_tokens=25 output_tokens=12 ...>
163
188
  ```
164
189
 
190
+ 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.
191
+
192
+ ```ruby
193
+ PromptBuilder::Response.parse({"error" => {"type" => "invalid_request_error", "message" => "Incorrect API key provided"}}, :chat_completion)
194
+ # => raises PromptBuilder::ErrorResponseError: the API returned an error: invalid_request_error: Incorrect API key provided
195
+ ```
196
+
165
197
  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
198
 
167
199
  ```ruby
@@ -298,6 +330,13 @@ session.use_tools(:weather, registry: my_registry) # explicit registry
298
330
 
299
331
  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
332
 
333
+ 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.
334
+
335
+ ```ruby
336
+ session.remove_tool("weather") # => removed Tools::Definition, or nil
337
+ session.clear_tools # => [<removed definitions>]
338
+ ```
339
+
301
340
  ### Content Types
302
341
 
303
342
  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 +457,8 @@ session = PromptBuilder::Session.new(
418
457
 
419
458
  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
459
 
460
+ 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.
461
+
421
462
  #### Session Extra
422
463
 
423
464
  Pass provider-specific top-level request parameters via `session.extra`:
@@ -471,6 +512,26 @@ session = PromptBuilder::Session.new(
471
512
  )
472
513
  ```
473
514
 
515
+ 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.
516
+
517
+ ```ruby
518
+ session = PromptBuilder::Session.new(model: "anthropic.claude-v2")
519
+
520
+ session.extra # => {}
521
+
522
+ # Assign the whole hash.
523
+ session.extra = {guardrail_config: {"guardrailIdentifier" => "my-guardrail", "guardrailVersion" => "1"}}
524
+
525
+ # `extra` returns a copy, so read, modify, and assign it back to change one key.
526
+ session.extra = session.extra.merge("stop_sequences" => ["\n\nHuman:"])
527
+ session.extra = session.extra.except("stop_sequences")
528
+
529
+ session.extra = nil # clears it
530
+ ```
531
+
532
+ > [!IMPORTANT]
533
+ > `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.
534
+
474
535
  #### Content Extra
475
536
 
476
537
  Content blocks support provider-specific attributes via keyword arguments that are captured in the `extra` hash:
@@ -500,6 +561,21 @@ session.user([
500
561
  cache_point: true
501
562
  )
502
563
  ])
564
+
565
+ # Bedrock Converse: cache_point on tool definitions and tool results
566
+ session.register_tool(
567
+ "search",
568
+ description: "Search the knowledge base.",
569
+ parameters: {"type" => "object", "properties" => {"query" => {"type" => "string"}}},
570
+ cache_point: true
571
+ )
572
+ session.add_item(
573
+ PromptBuilder::Items::FunctionCallOutput.new(
574
+ call_id: "call_1",
575
+ output: "Search results...",
576
+ cache_point: true
577
+ )
578
+ )
503
579
  ```
504
580
 
505
581
  #### Tool Definition Extra
@@ -518,12 +594,14 @@ session.register_tool(
518
594
 
519
595
  #### Recognized Extra Keys by Serializer
520
596
 
597
+ Session extra keys can be supplied to the constructor or assigned later with `session.extra=`.
598
+
521
599
  | Serializer | Session Extra Keys | Content Extra Keys | Tool Extra Keys |
522
600
  |:---|:---|:---|:---|
523
601
  | **Chat Completions** | `stop`, `seed`, `logit_bias`, `n`, `prediction`, `web_search_options`, `modalities`, `audio` | `file_id`, `media_type` | — |
524
602
  | **Messages** | `top_k`, `stop_sequences`, `cache_control` | `cache_control`, `citations`, `file_id`, `media_type` | `cache_control` |
525
603
  | **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` | |
604
+ | **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
605
 
528
606
  ### Serialization and Persistence
529
607
 
@@ -794,7 +872,7 @@ Content and message restrictions:
794
872
  - `FunctionCall.arguments` must parse to a JSON object; non-object JSON values raise (Bedrock's `toolUse.input` requires an object).
795
873
  - `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
874
  - `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.
875
+ - 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
876
  - 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
877
 
800
878
  Response-side limitations:
@@ -802,7 +880,7 @@ Response-side limitations:
802
880
  - Unknown content block keys (e.g. `citationsContent`, `guardContent`) are silently skipped.
803
881
  - `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
882
  - `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.
883
+ - `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
884
 
807
885
  ## Installation
808
886
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0
1
+ 0.4.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}"
@@ -158,10 +158,18 @@ module PromptBuilder
158
158
  # @param serializer_class [Class, Symbol] a serializer class (e.g. Serializers::ChatCompletion)
159
159
  # or a symbol shorthand (+:open_responses+, +:chat_completion+, +:messages+,
160
160
  # +:gemini+, +:converse+)
161
+ # @param headers [Hash, #each, nil] the HTTP response headers, for formats
162
+ # that return response metadata in headers rather than in the body
163
+ # (e.g. the Converse response id)
161
164
  # @return [Response]
162
165
  # @raise [ArgumentError] if a symbol is given that does not map to a known serializer
163
- def parse(hash, serializer_class)
164
- Serializers.resolve(serializer_class).parse_response(hash)
166
+ def parse(hash, serializer_class, headers: nil)
167
+ serializer = Serializers.resolve(serializer_class)
168
+ # Send the keyword only when headers are given, so serializer classes
169
+ # whose parse_response does not accept it keep working.
170
+ return serializer.parse_response(hash) if headers.nil?
171
+
172
+ serializer.parse_response(hash, headers: headers)
165
173
  end
166
174
 
167
175
  # Deserialize a Response from a Hash.
@@ -15,10 +15,20 @@ module PromptBuilder
15
15
  end
16
16
 
17
17
  # Parse a response from the target format into an PromptBuilder::Response.
18
+ # Format classes that define a nested Response parser delegate to it.
18
19
  #
19
20
  # @param hash [Hash] the response hash in the target format
21
+ # @param headers [Hash, #each, nil] the HTTP response headers, for
22
+ # formats that return response metadata in headers rather than in
23
+ # the body
20
24
  # @return [Response] the parsed response
21
- def parse_response(hash)
25
+ # @raise [ErrorResponseError] if the payload is an API error envelope
26
+ def parse_response(hash, headers: nil)
27
+ if const_defined?(:Response, false)
28
+ return const_get(:Response, false).parse_response(hash, headers: headers)
29
+ end
30
+
31
+ check_error_response!(hash)
22
32
  deserialize_response(hash)
23
33
  end
24
34
 
@@ -32,6 +42,32 @@ module PromptBuilder
32
42
  raise NotImplementedError
33
43
  end
34
44
 
45
+ # Detect API error envelopes before parsing so errors surface with the
46
+ # message reported by the API instead of the generic missing-key error
47
+ # from require_response_key!. Serializer response classes override
48
+ # error_response_message to recognize their format's error envelope.
49
+ #
50
+ # @param hash [Object] the raw response payload
51
+ # @return [void]
52
+ # @raise [ErrorResponseError] if the payload is an API error envelope
53
+ def check_error_response!(hash)
54
+ return unless hash.is_a?(Hash)
55
+
56
+ message = error_response_message(hash)
57
+ return if message.nil? || message.empty?
58
+
59
+ raise ErrorResponseError, "the API returned an error: #{message}"
60
+ end
61
+
62
+ # Extract a human-readable message from an API error envelope.
63
+ #
64
+ # @param _hash [Hash] the raw response payload
65
+ # @return [String, nil] nil when the payload is not recognized as an
66
+ # error envelope
67
+ def error_response_message(_hash)
68
+ nil
69
+ end
70
+
35
71
  # Ensure a response payload is a Hash containing the key that identifies
36
72
  # it as a well-formed response for this format. Raised before parsing so
37
73
  # 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
 
@@ -16,14 +16,6 @@ module PromptBuilder
16
16
  def request_payload(session)
17
17
  Request.request_payload(session)
18
18
  end
19
-
20
- # Parse a Chat Completions response into an PromptBuilder::Response.
21
- #
22
- # @param hash [Hash] the response hash in Chat Completions format
23
- # @return [PromptBuilder::Response] the parsed response
24
- def parse_response(hash)
25
- Response.parse_response(hash)
26
- end
27
19
  end
28
20
  end
29
21
  end
@@ -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
 
@@ -7,10 +7,42 @@ module PromptBuilder
7
7
  class Converse < Base
8
8
  # Response parser for the Amazon Bedrock Converse API format.
9
9
  class Response < Base
10
+ # The response id header set by Bedrock (the AWS request metadata id).
11
+ RESPONSE_ID_HEADER = "x-amzn-requestid"
12
+
10
13
  class << self
14
+ # Parse a Converse response into a PromptBuilder::Response. The
15
+ # Converse API does not include a response id in the body; Bedrock
16
+ # returns it in the request metadata headers, so the id is read
17
+ # from +headers+ when they are given.
18
+ #
19
+ # @param hash [Hash] the response hash in Converse format
20
+ # @param headers [Hash, #each, nil] the HTTP response headers
21
+ # @return [PromptBuilder::Response] the parsed response
22
+ # @raise [ErrorResponseError] if the payload is an API error envelope
23
+ def parse_response(hash, headers: nil)
24
+ check_error_response!(hash)
25
+ deserialize_response(hash, headers)
26
+ end
27
+
11
28
  private
12
29
 
13
- def deserialize_response(hash)
30
+ # Bedrock exception bodies carry a top-level "message" (the exception
31
+ # class travels in the x-amzn-ErrorType header) or a top-level
32
+ # "__type", while misrouted requests get a Coral service envelope
33
+ # like {"Output" => {"__type" => "..."}, "Version" => "1.0"}.
34
+ def error_response_message(hash)
35
+ return nil if hash.key?("output")
36
+
37
+ coral_output = hash["Output"].is_a?(Hash) ? hash["Output"] : {}
38
+ error_type = hash["__type"] || coral_output["__type"]
39
+ message = hash["message"] || hash["Message"] || coral_output["message"] || coral_output["Message"]
40
+ return nil unless error_type || message
41
+
42
+ [error_type, message].compact.join(": ")
43
+ end
44
+
45
+ def deserialize_response(hash, headers = nil)
14
46
  require_response_key!(hash, "output")
15
47
  require_response_key!(hash, "stopReason")
16
48
 
@@ -35,7 +67,7 @@ module PromptBuilder
35
67
  content_blocks = message["content"] || []
36
68
 
37
69
  PromptBuilder::Response.new(
38
- id: nil,
70
+ id: response_id_from_headers(headers),
39
71
  object: nil,
40
72
  model: nil,
41
73
  output: build_output_items(content_blocks),
@@ -46,6 +78,22 @@ module PromptBuilder
46
78
  )
47
79
  end
48
80
 
81
+ # Find the response id header with a case-insensitive name match.
82
+ # Header values may be scalars or arrays of values, depending on
83
+ # the HTTP client the headers come from.
84
+ def response_id_from_headers(headers)
85
+ return nil unless headers.respond_to?(:each)
86
+
87
+ headers.each do |name, value|
88
+ next unless name.to_s.casecmp?(RESPONSE_ID_HEADER)
89
+
90
+ value = value.first if value.is_a?(Array)
91
+ value = value.to_s.strip
92
+ return value unless value.empty?
93
+ end
94
+ nil
95
+ end
96
+
49
97
  def map_stop_reason(reason)
50
98
  case reason
51
99
  when "end_turn", "tool_use", "stop_sequence"
@@ -16,14 +16,6 @@ module PromptBuilder
16
16
  def request_payload(session)
17
17
  Request.request_payload(session)
18
18
  end
19
-
20
- # Parse a Converse response into a PromptBuilder::Response.
21
- #
22
- # @param hash [Hash] the response hash in Converse format
23
- # @return [PromptBuilder::Response] the parsed response
24
- def parse_response(hash)
25
- Response.parse_response(hash)
26
- end
27
19
  end
28
20
  end
29
21
  end
@@ -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")
@@ -16,14 +16,6 @@ module PromptBuilder
16
16
  def request_payload(session)
17
17
  Request.request_payload(session)
18
18
  end
19
-
20
- # Parse a Gemini response into a PromptBuilder::Response.
21
- #
22
- # @param hash [Hash] the response hash in Gemini format
23
- # @return [PromptBuilder::Response] the parsed response
24
- def parse_response(hash)
25
- Response.parse_response(hash)
26
- end
27
19
  end
28
20
  end
29
21
  end
@@ -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")
@@ -16,14 +16,6 @@ module PromptBuilder
16
16
  def request_payload(session)
17
17
  Request.request_payload(session)
18
18
  end
19
-
20
- # Parse a Messages response into an PromptBuilder::Response.
21
- #
22
- # @param hash [Hash] the response hash in Messages format
23
- # @return [PromptBuilder::Response] the parsed response
24
- def parse_response(hash)
25
- Response.parse_response(hash)
26
- end
27
19
  end
28
20
  end
29
21
  end
@@ -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")
@@ -16,14 +16,6 @@ module PromptBuilder
16
16
  def request_payload(session)
17
17
  Request.request_payload(session)
18
18
  end
19
-
20
- # Parse an Open Responses response into an PromptBuilder::Response.
21
- #
22
- # @param hash [Hash] the response hash in Open Responses format
23
- # @return [PromptBuilder::Response] the parsed response
24
- def parse_response(hash)
25
- Response.parse_response(hash)
26
- end
27
19
  end
28
20
  end
29
21
  end
@@ -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.4.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: []