agent-harness 0.44.20 → 0.44.22

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: 894fbdcf3b2dc1db0a3da49dbe98acc21c03b167f6ba2fe0574760ba74c18854
4
- data.tar.gz: cdfca3e9e6edc6f1704ac6336c190c2268a537b141bf2ba9cb7a3ffdb172d9d4
3
+ metadata.gz: bd3556ae8d2b4d460344b7479ad613d978baec38b54a7261bc4024314b2a5fd6
4
+ data.tar.gz: e5db97cdc1f923b2791d80bd3ca7b3dcf1060adc30a0c979f96da94e93740f63
5
5
  SHA512:
6
- metadata.gz: 3b94d9c2e2772489a160e8a645b5bc49b0f75682c05b2710643767379e7986da93cd11178745e3bb045fc06c47895c179f53e90939a55920b80f8dc08ec33d90
7
- data.tar.gz: 9bb666e9d727cd3d7cea914053dfa80849d450e8fc786d0234f227842fc95bf99ac97e9181f00d9431a1202b3ac65933888380fca5e99a5f49a4f3b77b916f81
6
+ metadata.gz: 38cba9849f8f085fe92b6c9bb5a7de4df67d4c3591a6d6a48459d391aaf4dc79ccbd8dbc684d667e103df3b2e3661dc82da8e4d88df9327ccf231741c1058525
7
+ data.tar.gz: 2567485b7f2c3f181d61d9124d43c6e6dd13bdf3ac6158687e6b5ebd0f75a8506a9e0b5dc0157ccc4fa4ff9a06ee52483b0f7571c4442de8fc3bdc59cdaef712
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.44.20"
2
+ ".": "0.44.22"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  ### Features
4
4
 
5
+ * support reasoning-capable OpenAI function-tool chat through the Responses API, including opaque reasoning state across tool-result continuation, streaming tool IDs and arguments, usage, cancellation, classified errors, an explicit compatible-provider protocol contract, and a gated live smoke test ([#499](https://github.com/viamin/agent-harness/issues/499)).
5
6
  * verify RDR-072's published capability artifacts and migration limits, retain the application chat loop over normalized transport, and remove the private RubyLLM streaming-refusal patch in favor of an explicit non-streaming schema boundary ([#437](https://github.com/viamin/agent-harness/issues/437)).
6
7
  * expose normalized API chat transport and schema-constrained responses with preserved JSON text, locally validated parsed values, and explicit refusal, truncation, invalid JSON, schema mismatch, and unsupported-mode outcomes; this combined delivery supersedes the separate chat-transport work item ([#433](https://github.com/viamin/agent-harness/issues/433), [#434](https://github.com/viamin/agent-harness/issues/434)).
7
8
  * add runner model compatibility contract (`AgentHarness.model_compatibility`) with structured `ModelCompatibility::Result` outcomes. Codex exposes static facts for CLI-gated models (e.g. `gpt-5.5` requires Codex CLI `>= 0.116.0`), a baseline supported-model list, supported auth modes, and a `DEFAULT_COMPATIBLE_MODEL_ID` fallback so downstream orchestrators can validate tier/model assignments before scheduling agent runs ([#259](https://github.com/viamin/agent-harness/issues/259)).
@@ -11,6 +12,20 @@
11
12
 
12
13
  * close the RDR-072 upstream epic from #437's retained-scope evidence while keeping unpublished capabilities and downstream adoption behind their explicit release and integration gates ([#430](https://github.com/viamin/agent-harness/issues/430)).
13
14
 
15
+ ## [0.44.22](https://github.com/viamin/agent-harness/compare/agent-harness/v0.44.21...agent-harness/v0.44.22) (2026-10-21)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * Preserve safe actionable provider error details in API chat failures ([#502](https://github.com/viamin/agent-harness/issues/502)) ([509a877](https://github.com/viamin/agent-harness/commit/509a8773e330cdc4bccb53edfe51bafef6b8e20f))
21
+
22
+ ## [0.44.21](https://github.com/viamin/agent-harness/compare/agent-harness/v0.44.20...agent-harness/v0.44.21) (2026-10-21)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * Support reasoning-capable OpenAI chat with function tools through Responses API ([#501](https://github.com/viamin/agent-harness/issues/501)) ([5976a89](https://github.com/viamin/agent-harness/commit/5976a89b2dacec48338eac8b2b45bc59976e4bd1))
28
+
14
29
  ## [0.44.20](https://github.com/viamin/agent-harness/compare/agent-harness/v0.44.19...agent-harness/v0.44.20) (2026-10-19)
15
30
 
16
31
 
data/README.md CHANGED
@@ -104,6 +104,55 @@ agent-harness release containing this capability:
104
104
  The runtime dependency is Ruby 3.2 or newer and RubyLLM 2.x. No Rails database
105
105
  or RubyLLM persistence tables are required for this plain-Ruby operation.
106
106
 
107
+ ## Native API chat
108
+
109
+ `AgentHarness::Api::ChatTransport` supports OpenAI's Responses API explicitly.
110
+ Select `protocol: :responses` for reasoning-capable OpenAI models used with
111
+ function tools; this preserves the model's reasoning behavior. Do not select
112
+ Chat Completions and do not disable reasoning or remove tools as a workaround.
113
+ OpenAI-compatible providers that only implement Chat Completions continue to
114
+ use `provider: :openai`, their explicit `endpoint`, and
115
+ `protocol: :chat_completions`; Agent Harness never probes or silently changes
116
+ the selected protocol.
117
+
118
+ ```ruby
119
+ candidate = {
120
+ provider: :openai,
121
+ model: "gpt-5.6-luna",
122
+ protocol: :responses,
123
+ authentication_mode: :api_key,
124
+ credentials: {api_key: ENV.fetch("OPENAI_API_KEY")}
125
+ }
126
+
127
+ result = AgentHarness::Api::ChatTransport.new.call(
128
+ request_id: SecureRandom.uuid,
129
+ operation: :chat,
130
+ candidates: [candidate],
131
+ messages: [{role: :user, content: "Look up order 42"}],
132
+ tools: [{
133
+ name: "lookup_order",
134
+ description: "Find an order",
135
+ input_schema: {type: "object", properties: {id: {type: "integer"}}, required: ["id"]}
136
+ }],
137
+ stream: true,
138
+ retry: {max_attempts: 1}
139
+ )
140
+ ```
141
+
142
+ The harness returns tool calls without executing them. For the next request,
143
+ append the assistant message (including `tool_calls` and `provider_state`) and
144
+ then a tool message whose `tool_call_id` is the returned stable harness ID.
145
+ `provider_state` is opaque protocol continuation data: persist and return it
146
+ unchanged, and never inspect or log it. The transport maps the stable ID back
147
+ to the provider call ID and replays the encrypted Responses reasoning item.
148
+
149
+ To run the credential-gated live tool-continuation smoke test:
150
+
151
+ ```bash
152
+ AGENT_HARNESS_LIVE_OPENAI=1 OPENAI_API_KEY=... \
153
+ bundle exec rspec spec/live/openai_responses_reasoning_spec.rb
154
+ ```
155
+
107
156
  ## Configuration
108
157
 
109
158
  ### Ruby DSL
@@ -247,7 +247,10 @@ The input is an ordered array. Each message has a stable caller-owned `id`, one
247
247
  of `system`, `user`, `assistant`, or `tool` as its `role`, and an ordered
248
248
  `content` array. Content parts initially support `text`; unimplemented media
249
249
  parts produce an explicit unsupported outcome. Assistant messages MAY contain
250
- tool calls. Tool messages MUST reference exactly one `tool_call_id`.
250
+ tool calls. A Responses assistant message that requests tools also carries the
251
+ opaque `provider_state` returned by the preceding result. Consumers MUST
252
+ persist and return that value unchanged and MUST NOT inspect or log it. Tool
253
+ messages MUST reference exactly one `tool_call_id`.
251
254
 
252
255
  Provider-specific wire roles, system-message placement, and content block
253
256
  shapes stay behind the harness boundary. The harness MUST preserve ordering,
@@ -275,6 +278,9 @@ reference the stable ID. Redelivery or restoration MUST skip a tool call when a
275
278
  completed result for that ID exists.
276
279
 
277
280
  The transport never decides approval and never executes application tools.
281
+ For stateless OpenAI Responses continuation, the harness uses `provider_state`
282
+ to replay the provider's encrypted reasoning and function-call output items.
283
+ The field is absent when the selected protocol does not require opaque state.
278
284
 
279
285
  ### Streaming
280
286
 
@@ -484,7 +490,13 @@ result/usage where available. Categories are:
484
490
 
485
491
  Adapters MUST use the following category and code mappings. A provider-specific
486
492
  status or error name may be retained as sanitized metadata, but MUST NOT replace
487
- these values or change fallback eligibility.
493
+ these values or change fallback eligibility. The normalized `message` may append
494
+ bounded, actionable detail for request, capability, configuration, and
495
+ response-shape failures. Such detail MUST redact credentials, headers, request
496
+ bodies, conversation content, and URLs, and MUST never copy an unparsed provider
497
+ response body or a message derived from one. Parser diagnostics are also omitted
498
+ because they can quote request or conversation content. Attempt reports retain
499
+ the same sanitized error object as the public result.
488
500
 
489
501
  | Failure | Category | Code |
490
502
  | --- | --- | --- |
@@ -3,6 +3,7 @@
3
3
  require "securerandom"
4
4
  require "ruby_llm"
5
5
  require_relative "attempt_report"
6
+ require_relative "provider_error_detail"
6
7
  require_relative "ruby_llm_chat_adapter"
7
8
  require_relative "schema_response"
8
9
 
@@ -150,6 +151,7 @@ module AgentHarness
150
151
  content: schema_result[:content],
151
152
  parsed: schema_result[:parsed],
152
153
  tool_calls: normalize_tool_calls(adapter_result[:tool_calls]),
154
+ provider_state: adapter_result[:provider_state],
153
155
  finish_reason: adapter_result[:finish_reason],
154
156
  usage: aggregate_usage,
155
157
  error: schema_result[:error]
@@ -442,6 +444,11 @@ module AgentHarness
442
444
  end
443
445
 
444
446
  module ErrorClassifier
447
+ ACTIONABLE_DETAIL_ERRORS = [
448
+ RubyLLM::BadRequestError,
449
+ RubyLlmChatAdapter::UnsupportedOptionError
450
+ ].freeze
451
+
445
452
  TRANSIENT = {
446
453
  Faraday::TimeoutError => :timeout,
447
454
  Faraday::ConnectionFailed => :connection_failed,
@@ -479,7 +486,7 @@ module AgentHarness
479
486
  end
480
487
 
481
488
  def self.payload(error, category, code, retryable)
482
- payload = {category: category, code: code, retryable: retryable, message: safe_message(category, code)}
489
+ payload = {category: category, code: code, retryable: retryable, message: safe_message(error, category, code)}
483
490
  retry_after = retry_after_seconds(error)
484
491
  payload[:retry_after_seconds] = retry_after if retry_after
485
492
  payload
@@ -496,8 +503,12 @@ module AgentHarness
496
503
  end
497
504
  private_class_method :retry_after_seconds
498
505
 
499
- def self.safe_message(category, code)
500
- "Chat request failed (#{category}/#{code})"
506
+ def self.safe_message(error, category, code)
507
+ summary = "Chat request failed (#{category}/#{code})"
508
+ return summary unless ACTIONABLE_DETAIL_ERRORS.any? { |klass| error.is_a?(klass) }
509
+
510
+ detail = ProviderErrorDetail.call(error)
511
+ detail.to_s.empty? ? summary : "#{summary}: #{detail}"
501
512
  end
502
513
  private_class_method :safe_message
503
514
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ module Api
5
+ # Extracts allowlisted, actionable text from provider errors without
6
+ # retaining response bodies or caller-controlled message content.
7
+ module ProviderErrorDetail
8
+ LOCAL_DETAILS = [
9
+ "RubyLLM does not support request-local connect timeouts",
10
+ "provider state does not match the selected protocol",
11
+ "provider state must carry at least one Responses output item",
12
+ "unsupported content type"
13
+ ].freeze
14
+ TOOL_REASONING_DETAIL = "Function tools with reasoning_effort are not supported for gpt-5.6-luna " \
15
+ "in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'."
16
+
17
+ module_function
18
+
19
+ def call(error)
20
+ message = normalized(error.message)
21
+ return if message.empty? || structured_body?(message) || !safe_source?(error, message)
22
+
23
+ message
24
+ end
25
+
26
+ def normalized(value)
27
+ value.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�")
28
+ .gsub(/[[:cntrl:]]+/, " ").gsub(/\s+/, " ").strip
29
+ end
30
+ private_class_method :normalized
31
+
32
+ def safe_source?(error, message)
33
+ safe_local_detail?(error, message) || safe_provider_detail?(error, message)
34
+ end
35
+ private_class_method :safe_source?
36
+
37
+ def safe_local_detail?(error, message)
38
+ error.is_a?(RubyLlmChatAdapter::UnsupportedOptionError) && LOCAL_DETAILS.include?(message)
39
+ end
40
+ private_class_method :safe_local_detail?
41
+
42
+ # RubyLLM parses this provider diagnostic from a JSON error field before
43
+ # constructing BadRequestError. Checking both the structured body and its
44
+ # fixed, non-content-bearing shape avoids exposing raw bodies or arbitrary
45
+ # provider prose that may echo request or conversation content.
46
+ def safe_provider_detail?(error, message)
47
+ error.is_a?(RubyLLM::BadRequestError) && message == TOOL_REASONING_DETAIL &&
48
+ structured_messages(error.response).include?(message)
49
+ end
50
+ private_class_method :safe_provider_detail?
51
+
52
+ def structured_messages(response)
53
+ body = response&.body
54
+ parsed = body.is_a?(String) ? JSON.parse(body) : body
55
+ return [] unless parsed.is_a?(Hash)
56
+
57
+ error = parsed["error"] || parsed[:error]
58
+ [error, parsed].filter_map { |part| part["message"] || part[:message] if part.is_a?(Hash) }
59
+ rescue JSON::ParserError
60
+ []
61
+ end
62
+ private_class_method :structured_messages
63
+
64
+ def structured_body?(message)
65
+ message.start_with?("{", "[", "<")
66
+ end
67
+ private_class_method :structured_body?
68
+ end
69
+ end
70
+ end
@@ -26,8 +26,8 @@ module AgentHarness
26
26
  provider_usage_reported = true if event[:type] == :usage_updated
27
27
  on_event&.call(event)
28
28
  end
29
- emit_completed_tool_calls(response, &on_event) if stream
30
- normalize_response(response)
29
+ emit_completed_tool_calls(response, &on_event) if stream && on_event
30
+ normalize_response(response, candidate)
31
31
  end
32
32
 
33
33
  def prepare(candidate:, messages:, tools:, max_output_tokens:, temperature:, timeout:, on_accounting: nil,
@@ -74,6 +74,66 @@ module AgentHarness
74
74
  end
75
75
  end
76
76
 
77
+ # Captures the opaque Responses output items that continue a turn.
78
+ # RubyLLM exposes the provider output only when it kept it: an
79
+ # unstreamed call parses the response body, and a streamed one keeps the
80
+ # output for namespaced calls and server tools. A streamed turn that used
81
+ # plain function calls leaves neither, so its replay items are rebuilt
82
+ # from the reasoning signature and tool calls the stream accumulator
83
+ # retained, in the same shape the Responses protocol itself replays.
84
+ class ResponsesOutput
85
+ def self.from(message)
86
+ new(message).items
87
+ end
88
+
89
+ def initialize(message)
90
+ @message = message
91
+ end
92
+
93
+ def items
94
+ retained_output || replay_items
95
+ end
96
+
97
+ private
98
+
99
+ def retained_output
100
+ [parsed_body_output, @message.raw_content].find { |output| output.is_a?(Array) && output.any? }
101
+ end
102
+
103
+ # A streamed response's raw is the Faraday response, whose body the
104
+ # adapter empties, so only an unstreamed body is a parsed Hash here.
105
+ def parsed_body_output
106
+ body = @message.raw&.body
107
+ body["output"] if body.is_a?(Hash)
108
+ end
109
+
110
+ def replay_items
111
+ [reasoning_item, message_item, *function_call_items].compact
112
+ end
113
+
114
+ def reasoning_item
115
+ thinking = @message.thinking
116
+ return unless thinking&.signature
117
+
118
+ summary = thinking.text.to_s.empty? ? [] : [{type: "summary_text", text: thinking.text}]
119
+ {"type" => "reasoning", "summary" => summary, "encrypted_content" => thinking.signature}
120
+ end
121
+
122
+ def message_item
123
+ content = @message.content.to_s
124
+ return if content.strip.empty?
125
+
126
+ {"role" => "assistant", "content" => [{"type" => "output_text", "text" => content}]}
127
+ end
128
+
129
+ def function_call_items
130
+ Array(@message.tool_calls&.values).map do |call|
131
+ {"type" => "function_call", "call_id" => call.id, "name" => call.name,
132
+ "arguments" => JSON.generate(call.arguments || {})}
133
+ end
134
+ end
135
+ end
136
+
77
137
  private
78
138
 
79
139
  def ruby_llm_protocol(candidate)
@@ -151,7 +211,7 @@ module AgentHarness
151
211
  end
152
212
 
153
213
  def configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature, schema)
154
- chat.messages = normalize_messages(messages)
214
+ chat.messages = normalize_messages(messages, candidate)
155
215
  chat.with_tools(tools.map { |tool| normalized_tool(tool) }) unless tools.empty?
156
216
  chat.with_headers(candidate[:headers] || {})
157
217
  chat.with_max_output_tokens(max_output_tokens) if max_output_tokens
@@ -185,17 +245,43 @@ module AgentHarness
185
245
  token.respond_to?(:cancelled?) ? token.cancelled? : token.call
186
246
  end
187
247
 
188
- def normalize_messages(messages)
248
+ def normalize_messages(messages, candidate)
189
249
  stable_to_provider = tool_id_map(messages)
190
250
  messages.map do |message|
191
251
  role = message.fetch(:role).to_sym
192
252
  normalized = {role: role, content: text_content(message[:content])}
193
253
  normalized[:tool_calls] = normalize_input_tool_calls(message[:tool_calls]) if message[:tool_calls]
194
254
  normalized[:tool_call_id] = stable_to_provider.fetch(message[:tool_call_id], message[:tool_call_id]) if role == :tool
255
+ apply_provider_state(normalized, message[:provider_state], candidate) if role == :assistant
195
256
  normalized
196
257
  end
197
258
  end
198
259
 
260
+ def apply_provider_state(message, state, candidate)
261
+ return unless state
262
+
263
+ validate_provider_state(state, candidate)
264
+ message[:raw_content] = state[:output]
265
+ end
266
+
267
+ # Provider state crosses a caller-owned boundary, so anything the
268
+ # selected protocol cannot replay as an assistant turn is rejected here
269
+ # rather than sent: an empty item list would drop the message content and
270
+ # its tool calls from the request.
271
+ def validate_provider_state(state, candidate)
272
+ unless responses_protocol?(candidate) && state[:protocol]&.to_sym == :responses
273
+ raise UnsupportedOptionError, "provider state does not match the selected protocol"
274
+ end
275
+
276
+ return if state[:output].is_a?(Array) && state[:output].any?
277
+
278
+ raise UnsupportedOptionError, "provider state must carry at least one Responses output item"
279
+ end
280
+
281
+ def responses_protocol?(candidate)
282
+ candidate[:provider].to_s == "openai" && candidate[:protocol].to_s == "responses"
283
+ end
284
+
199
285
  def tool_id_map(messages)
200
286
  messages.each_with_object({}) do |message, ids|
201
287
  Array(message[:tool_calls]).each do |call|
@@ -217,7 +303,7 @@ module AgentHarness
217
303
 
218
304
  Array(content).map do |part|
219
305
  unless part[:type]&.to_sym == :text
220
- raise UnsupportedOptionError, "unsupported content type: #{part[:type]}"
306
+ raise UnsupportedOptionError, "unsupported content type"
221
307
  end
222
308
 
223
309
  part[:text].to_s
@@ -301,17 +387,27 @@ module AgentHarness
301
387
  end
302
388
  end
303
389
 
304
- def normalize_response(response)
390
+ def normalize_response(response, candidate)
305
391
  {
306
392
  content: response.content || "",
307
393
  model: response.model,
308
394
  finish_reason: response.finish_reason,
309
395
  usage: normalize_usage(response.tokens),
310
396
  tool_calls: Array(response.tool_calls&.values).map { |call| normalize_tool_call(call) },
397
+ provider_state: provider_state(response, candidate),
311
398
  refusal: refusal?(response)
312
399
  }
313
400
  end
314
401
 
402
+ # Only the Responses protocol carries opaque continuation state; other
403
+ # protocols replay a turn from the normalized messages alone.
404
+ def provider_state(response, candidate)
405
+ return unless responses_protocol?(candidate)
406
+
407
+ output = ResponsesOutput.from(response)
408
+ {protocol: :responses, output: output} if output.any?
409
+ end
410
+
315
411
  def refusal?(response)
316
412
  return true if response.finish_reason == :content_filter
317
413
  return false unless response.respond_to?(:raw)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.44.20"
4
+ VERSION = "0.44.22"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agent-harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.44.20
4
+ version: 0.44.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -145,6 +145,7 @@ files:
145
145
  - lib/agent_harness.rb
146
146
  - lib/agent_harness/api/attempt_report.rb
147
147
  - lib/agent_harness/api/chat_transport.rb
148
+ - lib/agent_harness/api/provider_error_detail.rb
148
149
  - lib/agent_harness/api/ruby_llm_chat_adapter.rb
149
150
  - lib/agent_harness/api/schema_response.rb
150
151
  - lib/agent_harness/authentication.rb