agent-harness 0.41.0 → 0.43.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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +15 -0
- data/docs/provider-neutral-api-execution-contract.md +108 -16
- data/lib/agent_harness/api/attempt_report.rb +89 -0
- data/lib/agent_harness/api/chat_transport.rb +110 -22
- data/lib/agent_harness/api/ruby_llm_chat_adapter.rb +125 -19
- data/lib/agent_harness/api/schema_response.rb +43 -0
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +2 -0
- metadata +21 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 40d84099230c75237b23dca08330a46b739029768ce345b57cb232ba0e66a935
|
|
4
|
+
data.tar.gz: 47d36a8e160bcd29f9d50f6eabc1d0ded6795d6e05f2e45e93038d1987cd352c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f059d918fed14691fe444dba2e5e93aaeb2cdb185785a580b8a7341db8198616a0a830fd43461b4dfceffc9258f71ce25a8cbb69351747af46980ba0bd783757
|
|
7
|
+
data.tar.gz: 2f09db993da48a0ca0ed852eee9653898f474e7c61a6e0603dde3588457d226c8f51de71a8b322e7eba46b7e64203cc61a7607aa31764ed63188ef8b38f7c1e5
|
data/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
### Features
|
|
4
4
|
|
|
5
|
+
* 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)).
|
|
5
6
|
* 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)).
|
|
6
7
|
* **auth:** add provider-owned PKCE code-exchange API for Claude OAuth (`AgentHarness::Authentication.exchange_code`). Takes an authorization code plus PKCE verifier (and `redirect_uri`/`client_id`), posts an `authorization_code` grant to the Claude token endpoint, and persists the resulting access/refresh tokens in the native `claudeAiOauth` shape. Adds `exchange_code_supported?` and a `code_exchange` key to `auth_capabilities` ([#266](https://github.com/viamin/agent-harness/issues/266)).
|
|
7
8
|
|
|
9
|
+
## [0.43.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.42.0...agent-harness/v0.43.0) (2026-09-25)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* Expose Schema-Constrained Parsed Responses (RDR-072) ([#442](https://github.com/viamin/agent-harness/issues/442)) ([3d05cfa](https://github.com/viamin/agent-harness/commit/3d05cfac68624c218eda5f6f9871a3a4d1044ec0))
|
|
15
|
+
|
|
16
|
+
## [0.42.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.41.0...agent-harness/v0.42.0) (2026-09-25)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
* Expose Attempt-Level Usage and Cost (RDR-072) ([#443](https://github.com/viamin/agent-harness/issues/443)) ([818f8ca](https://github.com/viamin/agent-harness/commit/818f8ca0f873f002fc4d26b2a864238ae41fa866))
|
|
22
|
+
|
|
8
23
|
## [0.41.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.40.0...agent-harness/v0.41.0) (2026-09-25)
|
|
9
24
|
|
|
10
25
|
|
|
@@ -8,8 +8,8 @@ usage, and optional conversation-persistence work.
|
|
|
8
8
|
## Status and rollout boundary
|
|
9
9
|
|
|
10
10
|
RDR-072's rollout guard was **docs-only** for the design phase. The normalized
|
|
11
|
-
chat
|
|
12
|
-
implemented. Other capabilities remain design contracts and each still needs
|
|
11
|
+
chat, attempt-accounting, schema, and embedding capabilities described below
|
|
12
|
+
are now implemented. Other capabilities remain design contracts and each still needs
|
|
13
13
|
its own failing-first contract tests, implementation, release evidence, and
|
|
14
14
|
downstream adoption evidence before a caller enables it.
|
|
15
15
|
|
|
@@ -70,11 +70,13 @@ Responses API must select `:chat_completions`; the transport never probes and
|
|
|
70
70
|
silently switches protocols. Only `authentication_mode: :api_key` is currently
|
|
71
71
|
supported.
|
|
72
72
|
|
|
73
|
-
Credentials, endpoint, custom headers, timeout, and RubyLLM configuration
|
|
74
|
-
isolated with a request-local `RubyLLM::Context`.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
Credentials, endpoint, custom headers, read timeout, and RubyLLM configuration
|
|
74
|
+
are isolated with a request-local `RubyLLM::Context`. Only
|
|
75
|
+
`timeout.read_seconds` is supported; request-local connection timeouts are not.
|
|
76
|
+
RubyLLM middleware retries are disabled; `retry.max_attempts` is the total
|
|
77
|
+
physical-attempt limit owned by the harness. Authentication headers cannot be
|
|
78
|
+
overridden by custom headers. `max_output_tokens` is forwarded without changing
|
|
79
|
+
it.
|
|
78
80
|
|
|
79
81
|
Unknown model IDs are allowed only because a complete provider and protocol
|
|
80
82
|
are explicit in every candidate (`assume_model_exists: true` in the RubyLLM
|
|
@@ -84,6 +86,41 @@ endpoints retain the selected provider's wire protocol and authentication
|
|
|
84
86
|
shape. Custom provider types, authentication modes, media content, and
|
|
85
87
|
automatic protocol discovery are not supported by this capability.
|
|
86
88
|
|
|
89
|
+
## Shipped schema-constrained response surface
|
|
90
|
+
|
|
91
|
+
The same transport accepts `operation: :schema` with a JSON Schema and an
|
|
92
|
+
optional name:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
result = transport.call(request.merge(
|
|
96
|
+
operation: :schema,
|
|
97
|
+
schema_name: "person",
|
|
98
|
+
schema: {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {name: {type: "string"}, age: {type: "integer"}},
|
|
101
|
+
required: %w[name age],
|
|
102
|
+
additionalProperties: false
|
|
103
|
+
}
|
|
104
|
+
))
|
|
105
|
+
|
|
106
|
+
result[:content] # => '{"name":"Ada","age":37}'
|
|
107
|
+
result[:parsed] # => {"name" => "Ada", "age" => 37}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Schema operations use provider-native JSON Schema output and the same verified
|
|
111
|
+
Anthropic Messages, OpenAI Responses, and OpenAI Chat Completions scopes as
|
|
112
|
+
normalized chat. Provider adapters infer strictness from the schema; callers
|
|
113
|
+
using a `{schema:, strict:}` envelope can explicitly select strictness.
|
|
114
|
+
`schema_mode: :json_schema` is the only supported mode. JSON-only mode returns
|
|
115
|
+
`unsupported/structured_output_not_supported`.
|
|
116
|
+
|
|
117
|
+
The harness parses the exact provider text and validates it locally. It does
|
|
118
|
+
not remove Markdown fences or repair malformed JSON. Invalid JSON, schema
|
|
119
|
+
mismatch, refusal, and output-limit truncation return non-retryable
|
|
120
|
+
`invalid_response` outcomes with codes `invalid_json`, `invalid_schema`,
|
|
121
|
+
`refusal`, and `truncated_output`. They retain the original `content` and leave
|
|
122
|
+
`parsed` as `nil`.
|
|
123
|
+
|
|
87
124
|
## Ownership boundary
|
|
88
125
|
|
|
89
126
|
AgentHarness owns protocol translation and one bounded provider request:
|
|
@@ -139,7 +176,7 @@ request = {
|
|
|
139
176
|
}
|
|
140
177
|
],
|
|
141
178
|
fallback: {on_error_categories: [:transient]},
|
|
142
|
-
timeout: {
|
|
179
|
+
timeout: {read_seconds: 60},
|
|
143
180
|
retry: {max_attempts: 3, base_delay_seconds: 0.25, max_delay_seconds: 2},
|
|
144
181
|
cancellation: cancellation_token,
|
|
145
182
|
metadata: {tenant_id: "tenant-123", workflow_id: "workflow-456"}
|
|
@@ -160,9 +197,11 @@ part of a serializable request document.
|
|
|
160
197
|
gets a distinct `attempt_id`. Redelivering an already reported attempt retains
|
|
161
198
|
its `attempt_id`; initiating another outbound request does not.
|
|
162
199
|
|
|
163
|
-
Credentials, endpoint, headers,
|
|
164
|
-
request-local.
|
|
165
|
-
|
|
200
|
+
Credentials, endpoint, headers, the read timeout, retry limits, and cancellation
|
|
201
|
+
are request-local. Only `timeout.read_seconds` is supported; supplying a
|
|
202
|
+
connection timeout returns `unsupported/unsupported_capability` before any
|
|
203
|
+
provider request. Implementations MUST prevent concurrent requests from
|
|
204
|
+
observing one another's credentials or headers. They MUST reject reserved header
|
|
166
205
|
overrides that would conflict with the selected protocol's authentication.
|
|
167
206
|
Logs and errors MUST NOT contain credentials, authorization headers, message
|
|
168
207
|
bodies, tool arguments, or full provider responses.
|
|
@@ -336,6 +375,23 @@ observer so durable accounting can persist an attempt even when no message is
|
|
|
336
375
|
created. Callers deduplicate on `attempt_id`. Cost identifies its source as
|
|
337
376
|
provider-reported or harness-estimated; unknown cost remains `nil`.
|
|
338
377
|
|
|
378
|
+
The observer receives `attempt_completed` after every physical request, with
|
|
379
|
+
the same report later returned in `result[:attempts]`. Delivery is at least
|
|
380
|
+
once across process recovery: consumers MUST enforce a unique key on
|
|
381
|
+
`attempt_id` and treat a repeated report as an idempotent upsert, not another
|
|
382
|
+
charge. `AttemptReport.from_h` restores JSON-decoded reports. Stored cost
|
|
383
|
+
includes USD component amounts, `source` (`provider_reported` or `estimated`),
|
|
384
|
+
and `priced_at`; restoration never consults current prices. Missing counts and
|
|
385
|
+
prices remain absent/`nil`, while a reported zero remains zero. Usage may also
|
|
386
|
+
include `cache_read_tokens`, `cache_write_tokens`, and `thinking_tokens`.
|
|
387
|
+
|
|
388
|
+
Attempt events contain only identifiers, provider/model, outcome, normalized
|
|
389
|
+
usage/cost, timestamps, and sanitized classified errors. They do not include
|
|
390
|
+
prompts, messages, request headers, endpoints, or credentials. RubyLLM API chat
|
|
391
|
+
requests are in this ledger. Existing CLI providers, `TextTransport`,
|
|
392
|
+
`OpenAICompatibleTransport`, token trackers, embeddings, and other
|
|
393
|
+
non-`Api::ChatTransport` paths remain outside it.
|
|
394
|
+
|
|
339
395
|
Only errors classified `transient` are eligible for bounded request retry:
|
|
340
396
|
connection failure, timeout before a partial stream, rate limit, server error,
|
|
341
397
|
service unavailable, and overload. Authentication, authorization, billing,
|
|
@@ -487,7 +543,7 @@ records.
|
|
|
487
543
|
| Contract area | RubyLLM 2.0 mapping | Decision or gap |
|
|
488
544
|
| --- | --- | --- |
|
|
489
545
|
| Chat/protocols | `RubyLLM.chat`, messages, tools, stream callbacks | Candidate adapter; normalize all values and errors |
|
|
490
|
-
| Schema | `with_schema
|
|
546
|
+
| Schema | `with_schema`; harness JSON parsing and validation | Implemented for the verified chat scopes; JSON-only mode stays distinct |
|
|
491
547
|
| Embeddings | `RubyLLM.embed` and normalized vectors/usage | Candidate first capability; persistence remains in Paid |
|
|
492
548
|
| Custom headers | `with_headers` | Candidate; contract-test merging and secret redaction |
|
|
493
549
|
| Endpoint/credentials | provider configuration | Global mutable configuration is unsuitable; require request-local isolation or an upstream-supported client boundary |
|
|
@@ -569,11 +625,47 @@ types stay behind the harness boundary.
|
|
|
569
625
|
|
|
570
626
|
## Compatibility and release evidence
|
|
571
627
|
|
|
628
|
+
### Attempt-accounting capability evidence
|
|
629
|
+
|
|
630
|
+
- Release: pending the first published version containing issue #435; a Git
|
|
631
|
+
branch or tag alone is not downstream adoption evidence.
|
|
632
|
+
- Scope: normalized chat through `Api::ChatTransport` for Anthropic Messages
|
|
633
|
+
and OpenAI Responses/Chat Completions (including compatible endpoints), with
|
|
634
|
+
request-local API-key authentication. It is stacked on the normalized chat
|
|
635
|
+
capability from #433.
|
|
636
|
+
- Verification: the API contract specs cover request-local credentials,
|
|
637
|
+
endpoint/header isolation, error classification, bounded non-nested retry,
|
|
638
|
+
fallback, cancellation, partial usage, cache usage, repeated identity,
|
|
639
|
+
observer redaction, and JSON reload with preserved pricing. The full upstream
|
|
640
|
+
suite and lint run on the repository's supported Ruby environment.
|
|
641
|
+
- Migration: no Rails tables or migrations are loaded or required. Paid and
|
|
642
|
+
agent-image consumer versions remain unverified and MUST NOT adopt this
|
|
643
|
+
capability until their integration suites record the exact released gem and
|
|
644
|
+
image versions.
|
|
645
|
+
- Retained paths: CLI/subscription providers, legacy HTTP transports,
|
|
646
|
+
embeddings, and token trackers remain outside this ledger and require
|
|
647
|
+
separate migration issues.
|
|
648
|
+
|
|
649
|
+
### Schema capability release evidence
|
|
650
|
+
|
|
651
|
+
- Publication: unreleased; record the first installable version before
|
|
652
|
+
downstream adoption.
|
|
653
|
+
- Verified scopes: `:schema` with Anthropic Messages, OpenAI Responses, and
|
|
654
|
+
OpenAI Chat Completions using API-key authentication, including compatible
|
|
655
|
+
endpoints that explicitly select Chat Completions.
|
|
656
|
+
- Contract coverage: valid and required-field schemas, classified failures,
|
|
657
|
+
bounded retries, cancellation, refusal, truncation, malformed JSON, and
|
|
658
|
+
schema mismatch.
|
|
659
|
+
- Retained paths: CLI and subscription execution remain on existing provider
|
|
660
|
+
interfaces. JSON-only mode and model-specific capability discovery are not
|
|
661
|
+
migrated.
|
|
662
|
+
|
|
572
663
|
AgentHarness currently supports Ruby 3.2 and later and must remain usable as a
|
|
573
|
-
plain Ruby gem. RubyLLM 2.0.0 itself supports Ruby 3.1.3 and later
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
dependency set
|
|
664
|
+
plain Ruby gem. RubyLLM 2.0.0 itself supports Ruby 3.1.3 and later and adds
|
|
665
|
+
Faraday, event-stream parsing, Schematist, Marcel, and Zeitwerk runtime
|
|
666
|
+
dependencies. Local response validation adds `json_schemer` and its bounded
|
|
667
|
+
dependency set. A capability issue must test the harness minimum Ruby version
|
|
668
|
+
before adoption.
|
|
577
669
|
|
|
578
670
|
Rails and Active Record remain optional. Requiring `agent_harness` in a process
|
|
579
671
|
without Rails MUST NOT load Active Record, connect to a database, or require
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module AgentHarness
|
|
6
|
+
module Api
|
|
7
|
+
# Serializable accounting facts for one physical provider request.
|
|
8
|
+
class AttemptReport
|
|
9
|
+
STATUSES = %i[succeeded failed cancelled partial].freeze
|
|
10
|
+
USAGE_KEYS = %i[input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens total_tokens].freeze
|
|
11
|
+
COST_KEYS = %i[input output cache_read cache_write thinking total].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :attributes
|
|
14
|
+
|
|
15
|
+
def self.from_h(attributes)
|
|
16
|
+
new(**symbolize(attributes))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.symbolize(value)
|
|
20
|
+
return value.to_h { |key, child| [key.to_sym, symbolize(child)] } if value.is_a?(Hash)
|
|
21
|
+
return value.map { |child| symbolize(child) } if value.is_a?(Array)
|
|
22
|
+
|
|
23
|
+
value
|
|
24
|
+
end
|
|
25
|
+
private_class_method :symbolize
|
|
26
|
+
|
|
27
|
+
def initialize(attempt_id:, request_id:, number:, provider:, model:, status:, started_at:, finished_at:,
|
|
28
|
+
usage: nil, cost: nil, provider_reported: false, error: nil)
|
|
29
|
+
@attributes = {
|
|
30
|
+
attempt_id: attempt_id.to_s, request_id: request_id.to_s, number: number,
|
|
31
|
+
provider: provider.to_sym, model: model&.to_s, status: status.to_sym,
|
|
32
|
+
started_at: timestamp(started_at), finished_at: timestamp(finished_at),
|
|
33
|
+
usage: normalize_usage(usage), cost: normalize_cost(cost),
|
|
34
|
+
provider_reported: provider_reported == true, error: error
|
|
35
|
+
}
|
|
36
|
+
validate!
|
|
37
|
+
deep_freeze(@attributes)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
deep_dup(attributes)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def timestamp(value)
|
|
47
|
+
value.respond_to?(:iso8601) ? value.iso8601(6) : Time.iso8601(value.to_s).utc.iso8601(6)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def normalize_usage(usage)
|
|
51
|
+
return unless usage
|
|
52
|
+
|
|
53
|
+
normalized = self.class.send(:symbolize, usage)
|
|
54
|
+
USAGE_KEYS.each_with_object({}) do |key, result|
|
|
55
|
+
result[key] = normalized[key] if normalized.key?(key)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize_cost(cost)
|
|
60
|
+
return unless cost
|
|
61
|
+
|
|
62
|
+
normalized = self.class.send(:symbolize, cost)
|
|
63
|
+
amounts = COST_KEYS.to_h { |key| [key, normalized[key]] }
|
|
64
|
+
amounts.merge(currency: normalized.fetch(:currency, "USD"), source: normalized.fetch(:source).to_sym,
|
|
65
|
+
priced_at: timestamp(normalized.fetch(:priced_at)))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate!
|
|
69
|
+
raise ArgumentError, "attempt_id is required" if attributes[:attempt_id].empty?
|
|
70
|
+
raise ArgumentError, "request_id is required" if attributes[:request_id].empty?
|
|
71
|
+
raise ArgumentError, "number must be a positive integer" unless attributes[:number].is_a?(Integer) && attributes[:number].positive?
|
|
72
|
+
raise ArgumentError, "unknown attempt status" unless STATUSES.include?(attributes[:status])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def deep_freeze(value)
|
|
76
|
+
value.each_value { |child| deep_freeze(child) } if value.is_a?(Hash)
|
|
77
|
+
value.each { |child| deep_freeze(child) } if value.is_a?(Array)
|
|
78
|
+
value.freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def deep_dup(value)
|
|
82
|
+
return value.to_h { |key, child| [key, deep_dup(child)] } if value.is_a?(Hash)
|
|
83
|
+
return value.map { |child| deep_dup(child) } if value.is_a?(Array)
|
|
84
|
+
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require "ruby_llm"
|
|
5
|
+
require_relative "attempt_report"
|
|
5
6
|
require_relative "ruby_llm_chat_adapter"
|
|
7
|
+
require_relative "schema_response"
|
|
6
8
|
|
|
7
9
|
module AgentHarness
|
|
8
10
|
module Api
|
|
@@ -53,6 +55,7 @@ module AgentHarness
|
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
def call
|
|
58
|
+
return unsupported_schema_result unless schema_mode_supported?
|
|
56
59
|
return cancelled_result unless active?
|
|
57
60
|
|
|
58
61
|
candidates.each_with_index do |candidate, candidate_index|
|
|
@@ -85,6 +88,19 @@ module AgentHarness
|
|
|
85
88
|
end
|
|
86
89
|
|
|
87
90
|
def perform_attempt(candidate)
|
|
91
|
+
accounting = nil
|
|
92
|
+
provider_usage = -> { !streamed_usage.nil? }
|
|
93
|
+
prepared_chat = @adapter.prepare(
|
|
94
|
+
candidate: candidate,
|
|
95
|
+
messages: request[:messages],
|
|
96
|
+
tools: request[:tools] || [],
|
|
97
|
+
max_output_tokens: request[:max_output_tokens],
|
|
98
|
+
temperature: request[:temperature],
|
|
99
|
+
timeout: request[:timeout],
|
|
100
|
+
schema: schema_payload,
|
|
101
|
+
on_accounting: ->(facts) { accounting = facts },
|
|
102
|
+
provider_usage: provider_usage
|
|
103
|
+
)
|
|
88
104
|
attempt_id = @id_generator.call
|
|
89
105
|
started_at = Time.now.utc
|
|
90
106
|
emitted = false
|
|
@@ -99,7 +115,10 @@ module AgentHarness
|
|
|
99
115
|
temperature: request[:temperature],
|
|
100
116
|
stream: request[:stream] == true,
|
|
101
117
|
timeout: request[:timeout],
|
|
102
|
-
cancellation: request[:cancellation]
|
|
118
|
+
cancellation: request[:cancellation],
|
|
119
|
+
schema: schema_payload,
|
|
120
|
+
on_accounting: ->(facts) { accounting = facts },
|
|
121
|
+
prepared_chat: prepared_chat
|
|
103
122
|
) do |event|
|
|
104
123
|
event = normalize_stream_event(event)
|
|
105
124
|
emitted = true if output_event?(event)
|
|
@@ -109,31 +128,47 @@ module AgentHarness
|
|
|
109
128
|
end
|
|
110
129
|
raise RubyLLM::CancelledError unless active?
|
|
111
130
|
|
|
112
|
-
success(candidate, attempt_id, started_at, adapter_result)
|
|
131
|
+
success(candidate, attempt_id, started_at, adapter_result, accounting)
|
|
113
132
|
rescue ObserverError
|
|
114
133
|
raise
|
|
115
134
|
rescue => error
|
|
116
|
-
|
|
135
|
+
return failed_result(classify(error), candidate) unless attempt_id
|
|
136
|
+
|
|
137
|
+
failure(candidate, attempt_id, started_at, classify(error), partial: emitted,
|
|
138
|
+
accounting: accounting || {
|
|
139
|
+
usage: streamed_usage, provider_reported: !streamed_usage.nil?
|
|
140
|
+
})
|
|
117
141
|
end
|
|
118
142
|
|
|
119
|
-
def success(candidate, attempt_id, started_at, adapter_result)
|
|
120
|
-
|
|
143
|
+
def success(candidate, attempt_id, started_at, adapter_result, accounting)
|
|
144
|
+
accounting ||= {usage: adapter_result[:usage], provider_reported: !adapter_result[:usage].nil?}
|
|
145
|
+
schema_result = normalize_schema_response(adapter_result)
|
|
146
|
+
status = schema_result[:error] ? :failed : :succeeded
|
|
147
|
+
append_attempt(candidate, attempt_id, started_at, status, error: schema_result[:error], **accounting)
|
|
121
148
|
result = base_result(candidate).merge(
|
|
122
|
-
status:
|
|
123
|
-
content:
|
|
149
|
+
status: status,
|
|
150
|
+
content: schema_result[:content],
|
|
151
|
+
parsed: schema_result[:parsed],
|
|
124
152
|
tool_calls: normalize_tool_calls(adapter_result[:tool_calls]),
|
|
125
153
|
finish_reason: adapter_result[:finish_reason],
|
|
126
154
|
usage: aggregate_usage,
|
|
127
|
-
error:
|
|
155
|
+
error: schema_result[:error]
|
|
128
156
|
)
|
|
129
|
-
|
|
157
|
+
event = (status == :succeeded) ? :response_completed : :response_failed
|
|
158
|
+
emit(event, attempt_id:, result: result)
|
|
130
159
|
result
|
|
131
160
|
end
|
|
132
161
|
|
|
133
|
-
def
|
|
162
|
+
def normalize_schema_response(adapter_result)
|
|
163
|
+
return {content: adapter_result[:content] || "", parsed: nil, error: nil} unless schema_operation?
|
|
164
|
+
|
|
165
|
+
SchemaResponse.new(schema_definition).call(adapter_result)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def failure(candidate, attempt_id, started_at, error, partial:, accounting:)
|
|
134
169
|
status = failure_status(error, partial)
|
|
135
170
|
error = error.merge(retryable: false) if partial
|
|
136
|
-
|
|
171
|
+
append_attempt(candidate, attempt_id, started_at, status, error: error, **accounting)
|
|
137
172
|
result = base_result(candidate).merge(
|
|
138
173
|
status: status,
|
|
139
174
|
content: partial ? @partial_content.dup : "",
|
|
@@ -178,8 +213,9 @@ module AgentHarness
|
|
|
178
213
|
def backoff
|
|
179
214
|
exponent = [attempts.length - 1, 0].max
|
|
180
215
|
delay = retry_config[:base_delay_seconds] * (2**exponent)
|
|
216
|
+
delay = [delay, @last_error[:retry_after_seconds].to_f].max
|
|
181
217
|
cap = retry_config[:max_delay_seconds]
|
|
182
|
-
remaining = cap
|
|
218
|
+
remaining = cap.nil? ? delay : [delay, cap].min
|
|
183
219
|
while remaining.positive? && active?
|
|
184
220
|
interval = [remaining, 0.05].min
|
|
185
221
|
@sleeper.call(interval)
|
|
@@ -227,7 +263,8 @@ module AgentHarness
|
|
|
227
263
|
|
|
228
264
|
def normalize_tool_calls(tool_calls)
|
|
229
265
|
Array(tool_calls).map do |call|
|
|
230
|
-
call.
|
|
266
|
+
id = call[:provider_id].nil? ? @id_generator.call : tool_id(call[:provider_id])
|
|
267
|
+
call.merge(id: id, status: :completed)
|
|
231
268
|
end
|
|
232
269
|
end
|
|
233
270
|
|
|
@@ -265,8 +302,10 @@ module AgentHarness
|
|
|
265
302
|
usage: aggregate_usage, error: error)
|
|
266
303
|
end
|
|
267
304
|
|
|
268
|
-
def
|
|
269
|
-
|
|
305
|
+
def append_attempt(candidate, attempt_id, started_at, status, error: nil, usage: nil, cost: nil,
|
|
306
|
+
provider_reported: false)
|
|
307
|
+
cost = cost.merge(priced_at: Time.now.utc) if cost
|
|
308
|
+
report = AttemptReport.new(
|
|
270
309
|
attempt_id: attempt_id,
|
|
271
310
|
request_id: request[:request_id],
|
|
272
311
|
number: attempts.length + 1,
|
|
@@ -276,20 +315,22 @@ module AgentHarness
|
|
|
276
315
|
started_at: started_at.iso8601(6),
|
|
277
316
|
finished_at: Time.now.utc.iso8601(6),
|
|
278
317
|
usage: usage,
|
|
279
|
-
cost:
|
|
280
|
-
provider_reported:
|
|
318
|
+
cost: cost,
|
|
319
|
+
provider_reported: provider_reported,
|
|
281
320
|
error: error
|
|
282
|
-
|
|
321
|
+
).attributes
|
|
322
|
+
attempts << report
|
|
323
|
+
emit(:attempt_completed, attempt_id:, attempt: report)
|
|
283
324
|
end
|
|
284
325
|
|
|
285
326
|
def aggregate_usage
|
|
286
327
|
reports = attempts.filter_map { |attempt| attempt[:usage] }
|
|
287
328
|
return if reports.empty?
|
|
288
329
|
|
|
289
|
-
%i[input_tokens output_tokens total_tokens].to_h do |key|
|
|
330
|
+
%i[input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens total_tokens].to_h do |key|
|
|
290
331
|
values = reports.filter_map { |usage| usage[key] }
|
|
291
332
|
[key, values.empty? ? nil : values.sum]
|
|
292
|
-
end
|
|
333
|
+
end.compact
|
|
293
334
|
end
|
|
294
335
|
|
|
295
336
|
def classify(error)
|
|
@@ -316,14 +357,48 @@ module AgentHarness
|
|
|
316
357
|
end
|
|
317
358
|
|
|
318
359
|
def validate!
|
|
319
|
-
|
|
360
|
+
unless %i[chat schema].include?(request[:operation]&.to_sym)
|
|
361
|
+
raise ArgumentError, "operation must be :chat or :schema"
|
|
362
|
+
end
|
|
320
363
|
raise ArgumentError, "request_id is required" if request[:request_id].to_s.empty?
|
|
321
364
|
raise ArgumentError, "candidates must not be empty" if !request[:candidates].is_a?(Array) || request[:candidates].empty?
|
|
322
365
|
raise ArgumentError, "messages must be an array" unless request[:messages].is_a?(Array)
|
|
366
|
+
raise ArgumentError, "schema is required for a schema operation" if schema_operation? && !request[:schema].is_a?(Hash)
|
|
323
367
|
validate_attempt_limit!
|
|
324
368
|
candidates.each { |candidate| validate_candidate!(candidate) }
|
|
325
369
|
end
|
|
326
370
|
|
|
371
|
+
def schema_operation?
|
|
372
|
+
request[:operation].to_sym == :schema
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def schema_mode_supported?
|
|
376
|
+
!schema_operation? || !request[:schema_mode] || request[:schema_mode].to_sym == :json_schema
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def schema_payload
|
|
380
|
+
return unless schema_operation?
|
|
381
|
+
|
|
382
|
+
raw = request[:schema]
|
|
383
|
+
return raw.merge(name: request[:schema_name] || raw[:name]) if raw[:schema]
|
|
384
|
+
|
|
385
|
+
{name: request[:schema_name] || raw[:title] || "response", schema: raw}
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def schema_definition
|
|
389
|
+
schema_payload.fetch(:schema)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def unsupported_schema_result
|
|
393
|
+
error = {
|
|
394
|
+
category: :unsupported,
|
|
395
|
+
code: :structured_output_not_supported,
|
|
396
|
+
retryable: false,
|
|
397
|
+
message: "Schema request failed (unsupported/structured_output_not_supported)"
|
|
398
|
+
}
|
|
399
|
+
failed_result(error, candidates.first)
|
|
400
|
+
end
|
|
401
|
+
|
|
327
402
|
def validate_attempt_limit!
|
|
328
403
|
limit = retry_config[:max_attempts]
|
|
329
404
|
raise ArgumentError, "retry.max_attempts must be a positive integer" unless limit.is_a?(Integer) && limit.positive?
|
|
@@ -402,10 +477,23 @@ module AgentHarness
|
|
|
402
477
|
end
|
|
403
478
|
|
|
404
479
|
def self.payload(error, category, code, retryable)
|
|
405
|
-
{category: category, code: code, retryable: retryable, message: safe_message(category, code)}
|
|
480
|
+
payload = {category: category, code: code, retryable: retryable, message: safe_message(category, code)}
|
|
481
|
+
retry_after = retry_after_seconds(error)
|
|
482
|
+
payload[:retry_after_seconds] = retry_after if retry_after
|
|
483
|
+
payload
|
|
406
484
|
end
|
|
407
485
|
private_class_method :payload
|
|
408
486
|
|
|
487
|
+
def self.retry_after_seconds(error)
|
|
488
|
+
return unless error.respond_to?(:response) && error.response
|
|
489
|
+
|
|
490
|
+
headers = error.response.respond_to?(:headers) ? error.response.headers : error.response[:response_headers]
|
|
491
|
+
value = headers&.find { |key, _| key.to_s.casecmp?("retry-after") }&.last
|
|
492
|
+
delay = Float(value, exception: false)
|
|
493
|
+
delay if delay&.finite? && delay >= 0
|
|
494
|
+
end
|
|
495
|
+
private_class_method :retry_after_seconds
|
|
496
|
+
|
|
409
497
|
def self.safe_message(category, code)
|
|
410
498
|
"Chat request failed (#{category}/#{code})"
|
|
411
499
|
end
|
|
@@ -5,6 +5,22 @@ require "ruby_llm"
|
|
|
5
5
|
|
|
6
6
|
module AgentHarness
|
|
7
7
|
module Api
|
|
8
|
+
# RubyLLM 2.0.0 flattens Responses API refusal deltas into ordinary text
|
|
9
|
+
# chunks and offers no public access to their event type. The gemspec pins
|
|
10
|
+
# that exact release so this compatibility shim cannot silently outlive the
|
|
11
|
+
# private parser shape it targets. Remove it when RubyLLM exposes refusals.
|
|
12
|
+
module RubyLlmResponsesStreamingRefusal
|
|
13
|
+
module RefusalChunk; end
|
|
14
|
+
|
|
15
|
+
def build_chunk(data)
|
|
16
|
+
super.tap do |chunk|
|
|
17
|
+
chunk.extend(RefusalChunk) if data["type"] == "response.refusal.delta"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
RubyLLM::Protocols::Responses.prepend(RubyLlmResponsesStreamingRefusal)
|
|
23
|
+
|
|
8
24
|
# Translates the normalized public chat values to RubyLLM public objects.
|
|
9
25
|
class RubyLlmChatAdapter
|
|
10
26
|
class UnsupportedOptionError < StandardError; end
|
|
@@ -14,16 +30,29 @@ module AgentHarness
|
|
|
14
30
|
anthropic: %i[anthropic_api_key anthropic_api_base],
|
|
15
31
|
openai: %i[openai_api_key openai_api_base]
|
|
16
32
|
}.freeze
|
|
33
|
+
DEFAULT_REQUEST_TIMEOUT = 300
|
|
17
34
|
OPENAI_UNSUPPLIED_CONFIG = %i[openai_organization_id openai_project_id openai_use_system_role].freeze
|
|
18
35
|
|
|
19
|
-
def call(candidate:, messages:, tools:, max_output_tokens:, temperature:, stream:, timeout:, cancellation:,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
response = generate(chat, stream, cancellation
|
|
36
|
+
def call(candidate:, messages:, tools:, max_output_tokens:, temperature:, stream:, timeout:, cancellation:,
|
|
37
|
+
schema: nil, on_accounting: nil, prepared_chat: nil, &on_event)
|
|
38
|
+
provider_usage_reported = false
|
|
39
|
+
chat = prepared_chat || prepare(candidate:, messages:, tools:, max_output_tokens:, temperature:, timeout:,
|
|
40
|
+
schema:, on_accounting:, provider_usage: -> { provider_usage_reported })
|
|
41
|
+
response, streamed_refusal = generate(chat, stream, cancellation) do |event|
|
|
42
|
+
provider_usage_reported = true if event[:type] == :usage_updated
|
|
43
|
+
on_event&.call(event)
|
|
44
|
+
end
|
|
25
45
|
emit_completed_tool_calls(response, &on_event) if stream
|
|
26
|
-
normalize_response(response)
|
|
46
|
+
normalize_response(response, streamed_refusal: streamed_refusal)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def prepare(candidate:, messages:, tools:, max_output_tokens:, temperature:, timeout:, on_accounting: nil,
|
|
50
|
+
schema: nil, provider_usage: -> { false })
|
|
51
|
+
context = build_context(candidate, timeout, on_accounting, provider_usage)
|
|
52
|
+
context.chat(model: candidate[:model], provider: candidate[:provider], protocol: ruby_llm_protocol(candidate),
|
|
53
|
+
assume_model_exists: true).tap do |chat|
|
|
54
|
+
configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature, schema)
|
|
55
|
+
end
|
|
27
56
|
end
|
|
28
57
|
|
|
29
58
|
# RubyLLM keys streamed tool-call chunks by a stream index while
|
|
@@ -32,6 +61,7 @@ module AgentHarness
|
|
|
32
61
|
# duplicate cumulative reports are not re-emitted.
|
|
33
62
|
class StreamState
|
|
34
63
|
attr_accessor :input_tokens, :output_tokens
|
|
64
|
+
attr_reader :refusal
|
|
35
65
|
|
|
36
66
|
def initialize
|
|
37
67
|
@provider_id_by_key = {}
|
|
@@ -39,6 +69,7 @@ module AgentHarness
|
|
|
39
69
|
@latest_provider_id = nil
|
|
40
70
|
@input_tokens = nil
|
|
41
71
|
@output_tokens = nil
|
|
72
|
+
@refusal = false
|
|
42
73
|
end
|
|
43
74
|
|
|
44
75
|
# Links a stream chunk key to its provider call id, returning true
|
|
@@ -59,6 +90,10 @@ module AgentHarness
|
|
|
59
90
|
total = (input_tokens + output_tokens) if input_tokens && output_tokens
|
|
60
91
|
{input_tokens: input_tokens, output_tokens: output_tokens, total_tokens: total}
|
|
61
92
|
end
|
|
93
|
+
|
|
94
|
+
def observe(chunk)
|
|
95
|
+
@refusal ||= chunk.is_a?(RubyLlmResponsesStreamingRefusal::RefusalChunk)
|
|
96
|
+
end
|
|
62
97
|
end
|
|
63
98
|
|
|
64
99
|
private
|
|
@@ -69,7 +104,7 @@ module AgentHarness
|
|
|
69
104
|
candidate[:protocol]
|
|
70
105
|
end
|
|
71
106
|
|
|
72
|
-
def build_context(candidate, timeout)
|
|
107
|
+
def build_context(candidate, timeout, on_accounting, provider_usage)
|
|
73
108
|
provider = candidate[:provider].to_sym
|
|
74
109
|
config_keys = PROVIDER_CONFIG[provider]
|
|
75
110
|
raise RubyLLM::ConfigurationError, "Unsupported chat provider: #{provider}" unless config_keys
|
|
@@ -80,10 +115,51 @@ module AgentHarness
|
|
|
80
115
|
config.public_send("#{config_keys[1]}=", candidate[:endpoint])
|
|
81
116
|
clear_unsupplied_openai_config(config) if provider == :openai
|
|
82
117
|
config.max_retries = 0
|
|
118
|
+
config.instrumenter = UsageInstrumenter.new(on_accounting, provider_usage) if on_accounting
|
|
83
119
|
apply_timeout(config, timeout)
|
|
84
120
|
end
|
|
85
121
|
end
|
|
86
122
|
|
|
123
|
+
# Retains only accounting instrumentation; other events may contain
|
|
124
|
+
# request content and are intentionally discarded.
|
|
125
|
+
class UsageInstrumenter
|
|
126
|
+
def initialize(callback, provider_usage)
|
|
127
|
+
@callback = callback
|
|
128
|
+
@provider_usage = provider_usage
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def instrument(name, payload)
|
|
132
|
+
@callback.call(normalize(payload)) if name == "usage.ruby_llm"
|
|
133
|
+
yield(payload) if block_given?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def normalize(payload)
|
|
139
|
+
tokens = payload.fetch(:tokens)
|
|
140
|
+
cost = payload.fetch(:cost)
|
|
141
|
+
usage = tokens.to_h
|
|
142
|
+
usage[:total_tokens] = tokens.input + tokens.output if tokens.input && tokens.output
|
|
143
|
+
{
|
|
144
|
+
usage: usage,
|
|
145
|
+
cost: cost.total.nil? ? nil : cost.to_h.merge(source: cost_source(tokens), currency: "USD"),
|
|
146
|
+
provider_reported: provider_reported?(payload, usage)
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def provider_reported?(payload, usage)
|
|
151
|
+
return false if usage.empty?
|
|
152
|
+
return true unless payload[:status]&.to_sym == :failed
|
|
153
|
+
return true if @provider_usage.call
|
|
154
|
+
|
|
155
|
+
usage.values.compact.any?(&:positive?)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def cost_source(tokens)
|
|
159
|
+
tokens.reported_cost.nil? ? :estimated : :provider_reported
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
87
163
|
def clear_unsupplied_openai_config(config)
|
|
88
164
|
OPENAI_UNSUPPLIED_CONFIG.each { |key| config.public_send("#{key}=", nil) }
|
|
89
165
|
end
|
|
@@ -93,23 +169,23 @@ module AgentHarness
|
|
|
93
169
|
raise UnsupportedOptionError, "RubyLLM does not support request-local connect timeouts"
|
|
94
170
|
end
|
|
95
171
|
|
|
96
|
-
|
|
97
|
-
config.request_timeout = seconds if seconds
|
|
172
|
+
config.request_timeout = timeout&.dig(:read_seconds) || DEFAULT_REQUEST_TIMEOUT
|
|
98
173
|
end
|
|
99
174
|
|
|
100
|
-
def configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature)
|
|
175
|
+
def configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature, schema)
|
|
101
176
|
chat.messages = normalize_messages(messages)
|
|
102
177
|
chat.with_tools(tools.map { |tool| normalized_tool(tool) }) unless tools.empty?
|
|
103
178
|
chat.with_headers(candidate[:headers] || {})
|
|
104
179
|
chat.with_max_output_tokens(max_output_tokens) if max_output_tokens
|
|
105
180
|
chat.with_temperature(temperature) unless temperature.nil?
|
|
181
|
+
chat.with_schema(schema) if schema
|
|
106
182
|
end
|
|
107
183
|
|
|
108
184
|
def generate(chat, stream, cancellation)
|
|
109
|
-
return generate_without_events(chat, cancellation) unless stream
|
|
185
|
+
return [generate_without_events(chat, cancellation), false] unless stream
|
|
110
186
|
|
|
111
187
|
state = StreamState.new
|
|
112
|
-
chat.generate do |chunk|
|
|
188
|
+
response = chat.generate do |chunk|
|
|
113
189
|
if cancelled?(cancellation)
|
|
114
190
|
chat.cancel
|
|
115
191
|
raise RubyLLM::CancelledError
|
|
@@ -117,6 +193,7 @@ module AgentHarness
|
|
|
117
193
|
|
|
118
194
|
stream_events(chunk, state).each { |event| yield event }
|
|
119
195
|
end
|
|
196
|
+
[response, state.refusal]
|
|
120
197
|
end
|
|
121
198
|
|
|
122
199
|
def generate_without_events(chat, cancellation)
|
|
@@ -182,6 +259,7 @@ module AgentHarness
|
|
|
182
259
|
end
|
|
183
260
|
|
|
184
261
|
def stream_events(chunk, state)
|
|
262
|
+
state.observe(chunk)
|
|
185
263
|
[text_event(chunk), *tool_call_events(chunk, state), usage_event(chunk, state)].compact
|
|
186
264
|
end
|
|
187
265
|
|
|
@@ -247,26 +325,54 @@ module AgentHarness
|
|
|
247
325
|
end
|
|
248
326
|
end
|
|
249
327
|
|
|
250
|
-
def normalize_response(response)
|
|
328
|
+
def normalize_response(response, streamed_refusal: false)
|
|
251
329
|
{
|
|
252
330
|
content: response.content || "",
|
|
253
331
|
model: response.model,
|
|
254
332
|
finish_reason: response.finish_reason,
|
|
255
333
|
usage: normalize_usage(response.tokens),
|
|
256
|
-
tool_calls: Array(response.tool_calls&.values).map { |call| normalize_tool_call(call) }
|
|
334
|
+
tool_calls: Array(response.tool_calls&.values).map { |call| normalize_tool_call(call) },
|
|
335
|
+
refusal: streamed_refusal || refusal?(response)
|
|
257
336
|
}
|
|
258
337
|
end
|
|
259
338
|
|
|
339
|
+
def refusal?(response)
|
|
340
|
+
return true if response.finish_reason == :content_filter
|
|
341
|
+
return false unless response.respond_to?(:raw)
|
|
342
|
+
|
|
343
|
+
body = response.raw&.body
|
|
344
|
+
return false unless body.is_a?(Hash)
|
|
345
|
+
|
|
346
|
+
responses_api_refusal?(body) || chat_completions_refusal?(body)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def responses_api_refusal?(body)
|
|
350
|
+
Array(body["output"] || body[:output]).any? do |item|
|
|
351
|
+
next false unless item.is_a?(Hash)
|
|
352
|
+
|
|
353
|
+
Array(item["content"] || item[:content]).any? do |part|
|
|
354
|
+
part.is_a?(Hash) && (part["type"] || part[:type]) == "refusal"
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def chat_completions_refusal?(body)
|
|
360
|
+
Array(body["choices"] || body[:choices]).any? do |choice|
|
|
361
|
+
next false unless choice.is_a?(Hash)
|
|
362
|
+
|
|
363
|
+
message = choice["message"] || choice[:message]
|
|
364
|
+
message.is_a?(Hash) && !(message["refusal"] || message[:refusal]).nil?
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
260
368
|
def normalize_tool_call(call)
|
|
261
369
|
{provider_id: call.id, name: call.name, arguments_json: JSON.generate(call.arguments || {})}
|
|
262
370
|
end
|
|
263
371
|
|
|
264
372
|
def normalize_usage(tokens)
|
|
265
|
-
|
|
266
|
-
output = tokens&.output
|
|
267
|
-
return unless input || output
|
|
373
|
+
return unless tokens&.to_h&.any?
|
|
268
374
|
|
|
269
|
-
|
|
375
|
+
tokens.to_h.merge(total_tokens: (tokens.input && tokens.output) ? tokens.input + tokens.output : nil)
|
|
270
376
|
end
|
|
271
377
|
end
|
|
272
378
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "json_schemer"
|
|
5
|
+
|
|
6
|
+
module AgentHarness
|
|
7
|
+
module Api
|
|
8
|
+
# Parses and validates one schema-constrained provider response.
|
|
9
|
+
class SchemaResponse
|
|
10
|
+
def initialize(schema)
|
|
11
|
+
@validator = JSONSchemer.schema(schema)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(response)
|
|
15
|
+
content = response[:content].to_s
|
|
16
|
+
return failure(content, :refusal) if response[:refusal]
|
|
17
|
+
return failure(content, :truncated_output) if response[:finish_reason]&.to_sym == :max_tokens
|
|
18
|
+
|
|
19
|
+
parsed = JSON.parse(content)
|
|
20
|
+
return failure(content, :invalid_schema) unless @validator.valid?(parsed)
|
|
21
|
+
|
|
22
|
+
{content: content, parsed: parsed, error: nil}
|
|
23
|
+
rescue JSON::ParserError
|
|
24
|
+
failure(content, :invalid_json)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def failure(content, code)
|
|
30
|
+
{
|
|
31
|
+
content: content,
|
|
32
|
+
parsed: nil,
|
|
33
|
+
error: {
|
|
34
|
+
category: :invalid_response,
|
|
35
|
+
code: code,
|
|
36
|
+
retryable: false,
|
|
37
|
+
message: "Schema response failed (invalid_response/#{code})"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/agent_harness.rb
CHANGED
|
@@ -471,7 +471,9 @@ require_relative "agent_harness/error_taxonomy"
|
|
|
471
471
|
require_relative "agent_harness/text_transport"
|
|
472
472
|
require_relative "agent_harness/openai_compatible_transport"
|
|
473
473
|
require_relative "agent_harness/conversation"
|
|
474
|
+
require_relative "agent_harness/api/attempt_report"
|
|
474
475
|
require_relative "agent_harness/api/chat_transport"
|
|
476
|
+
require_relative "agent_harness/api/schema_response"
|
|
475
477
|
require_relative "agent_harness/quota_status"
|
|
476
478
|
require_relative "agent_harness/authentication"
|
|
477
479
|
require_relative "agent_harness/provider_health_check"
|
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.
|
|
4
|
+
version: 0.43.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bart Agapinan
|
|
@@ -30,25 +30,33 @@ dependencies:
|
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '2.0'
|
|
32
32
|
- !ruby/object:Gem::Dependency
|
|
33
|
-
name:
|
|
33
|
+
name: json_schemer
|
|
34
34
|
requirement: !ruby/object:Gem::Requirement
|
|
35
35
|
requirements:
|
|
36
|
-
- - "
|
|
37
|
-
- !ruby/object:Gem::Version
|
|
38
|
-
version: '2.0'
|
|
39
|
-
- - "<"
|
|
36
|
+
- - "~>"
|
|
40
37
|
- !ruby/object:Gem::Version
|
|
41
|
-
version: '
|
|
38
|
+
version: '2.4'
|
|
42
39
|
type: :runtime
|
|
43
40
|
prerelease: false
|
|
44
41
|
version_requirements: !ruby/object:Gem::Requirement
|
|
45
42
|
requirements:
|
|
46
|
-
- - "
|
|
43
|
+
- - "~>"
|
|
47
44
|
- !ruby/object:Gem::Version
|
|
48
|
-
version: '2.
|
|
49
|
-
|
|
45
|
+
version: '2.4'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: ruby_llm
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - '='
|
|
50
51
|
- !ruby/object:Gem::Version
|
|
51
|
-
version:
|
|
52
|
+
version: 2.0.0
|
|
53
|
+
type: :runtime
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - '='
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: 2.0.0
|
|
52
60
|
- !ruby/object:Gem::Dependency
|
|
53
61
|
name: rake
|
|
54
62
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -135,8 +143,10 @@ files:
|
|
|
135
143
|
- json-2.18.1.gem
|
|
136
144
|
- lib/agent-harness.rb
|
|
137
145
|
- lib/agent_harness.rb
|
|
146
|
+
- lib/agent_harness/api/attempt_report.rb
|
|
138
147
|
- lib/agent_harness/api/chat_transport.rb
|
|
139
148
|
- lib/agent_harness/api/ruby_llm_chat_adapter.rb
|
|
149
|
+
- lib/agent_harness/api/schema_response.rb
|
|
140
150
|
- lib/agent_harness/authentication.rb
|
|
141
151
|
- lib/agent_harness/command_executor.rb
|
|
142
152
|
- lib/agent_harness/configuration.rb
|