agent-harness 0.41.0 → 0.42.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 +7 -0
- data/docs/provider-neutral-api-execution-contract.md +40 -2
- data/lib/agent_harness/api/attempt_report.rb +89 -0
- data/lib/agent_harness/api/chat_transport.rb +57 -17
- data/lib/agent_harness/api/ruby_llm_chat_adapter.rb +64 -13
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9c2a3cf60957839163bbda4f4e202f663ee68915e49ba3853abce85b5f6f16e9
|
|
4
|
+
data.tar.gz: b5a9950414fc72fe2626abbb3ef50275e2e28267346414d04d330d3ea37c1297
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b6147210553af7975d99dbf53cc52c0b37e7ba25c8424a9cfb24a290bae9172d25bcbc8a6e5b489d1b2ae363251facc756ea0eaae55a6efd3904cdb64fcda025
|
|
7
|
+
data.tar.gz: f869749041ff450642b8ba940a3f0f9f8ea2cd7a3d788b2414ae7f0115dcb5fde4dcb7f5aa725b4e1ba2216568a1412e2bf5439c47b61bf75b2d06390faebfae
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* 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
6
|
* **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
7
|
|
|
8
|
+
## [0.42.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.41.0...agent-harness/v0.42.0) (2026-09-25)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* 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))
|
|
14
|
+
|
|
8
15
|
## [0.41.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.40.0...agent-harness/v0.41.0) (2026-09-25)
|
|
9
16
|
|
|
10
17
|
|
|
@@ -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
|
-
|
|
11
|
+
chat and attempt-accounting capabilities described below are now implemented
|
|
12
|
+
behind `AgentHarness::Api::ChatTransport`. 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
|
|
|
@@ -336,6 +336,23 @@ observer so durable accounting can persist an attempt even when no message is
|
|
|
336
336
|
created. Callers deduplicate on `attempt_id`. Cost identifies its source as
|
|
337
337
|
provider-reported or harness-estimated; unknown cost remains `nil`.
|
|
338
338
|
|
|
339
|
+
The observer receives `attempt_completed` after every physical request, with
|
|
340
|
+
the same report later returned in `result[:attempts]`. Delivery is at least
|
|
341
|
+
once across process recovery: consumers MUST enforce a unique key on
|
|
342
|
+
`attempt_id` and treat a repeated report as an idempotent upsert, not another
|
|
343
|
+
charge. `AttemptReport.from_h` restores JSON-decoded reports. Stored cost
|
|
344
|
+
includes USD component amounts, `source` (`provider_reported` or `estimated`),
|
|
345
|
+
and `priced_at`; restoration never consults current prices. Missing counts and
|
|
346
|
+
prices remain absent/`nil`, while a reported zero remains zero. Usage may also
|
|
347
|
+
include `cache_read_tokens`, `cache_write_tokens`, and `thinking_tokens`.
|
|
348
|
+
|
|
349
|
+
Attempt events contain only identifiers, provider/model, outcome, normalized
|
|
350
|
+
usage/cost, timestamps, and sanitized classified errors. They do not include
|
|
351
|
+
prompts, messages, request headers, endpoints, or credentials. RubyLLM API chat
|
|
352
|
+
requests are in this ledger. Existing CLI providers, `TextTransport`,
|
|
353
|
+
`OpenAICompatibleTransport`, token trackers, embeddings, and other
|
|
354
|
+
non-`Api::ChatTransport` paths remain outside it.
|
|
355
|
+
|
|
339
356
|
Only errors classified `transient` are eligible for bounded request retry:
|
|
340
357
|
connection failure, timeout before a partial stream, rate limit, server error,
|
|
341
358
|
service unavailable, and overload. Authentication, authorization, billing,
|
|
@@ -569,6 +586,27 @@ types stay behind the harness boundary.
|
|
|
569
586
|
|
|
570
587
|
## Compatibility and release evidence
|
|
571
588
|
|
|
589
|
+
### Attempt-accounting capability evidence
|
|
590
|
+
|
|
591
|
+
- Release: pending the first published version containing issue #435; a Git
|
|
592
|
+
branch or tag alone is not downstream adoption evidence.
|
|
593
|
+
- Scope: normalized chat through `Api::ChatTransport` for Anthropic Messages
|
|
594
|
+
and OpenAI Responses/Chat Completions (including compatible endpoints), with
|
|
595
|
+
request-local API-key authentication. It is stacked on the normalized chat
|
|
596
|
+
capability from #433.
|
|
597
|
+
- Verification: the API contract specs cover request-local credentials,
|
|
598
|
+
endpoint/header isolation, error classification, bounded non-nested retry,
|
|
599
|
+
fallback, cancellation, partial usage, cache usage, repeated identity,
|
|
600
|
+
observer redaction, and JSON reload with preserved pricing. The full upstream
|
|
601
|
+
suite and lint run on the repository's supported Ruby environment.
|
|
602
|
+
- Migration: no Rails tables or migrations are loaded or required. Paid and
|
|
603
|
+
agent-image consumer versions remain unverified and MUST NOT adopt this
|
|
604
|
+
capability until their integration suites record the exact released gem and
|
|
605
|
+
image versions.
|
|
606
|
+
- Retained paths: CLI/subscription providers, legacy HTTP transports,
|
|
607
|
+
embeddings, and token trackers remain outside this ledger and require
|
|
608
|
+
separate migration issues.
|
|
609
|
+
|
|
572
610
|
AgentHarness currently supports Ruby 3.2 and later and must remain usable as a
|
|
573
611
|
plain Ruby gem. RubyLLM 2.0.0 itself supports Ruby 3.1.3 and later, but adding it
|
|
574
612
|
would introduce Faraday, event-stream parsing, Schematist, Marcel, and Zeitwerk
|
|
@@ -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,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require "ruby_llm"
|
|
5
|
+
require_relative "attempt_report"
|
|
5
6
|
require_relative "ruby_llm_chat_adapter"
|
|
6
7
|
|
|
7
8
|
module AgentHarness
|
|
@@ -85,6 +86,18 @@ module AgentHarness
|
|
|
85
86
|
end
|
|
86
87
|
|
|
87
88
|
def perform_attempt(candidate)
|
|
89
|
+
accounting = nil
|
|
90
|
+
provider_usage = -> { !streamed_usage.nil? }
|
|
91
|
+
prepared_chat = @adapter.prepare(
|
|
92
|
+
candidate: candidate,
|
|
93
|
+
messages: request[:messages],
|
|
94
|
+
tools: request[:tools] || [],
|
|
95
|
+
max_output_tokens: request[:max_output_tokens],
|
|
96
|
+
temperature: request[:temperature],
|
|
97
|
+
timeout: request[:timeout],
|
|
98
|
+
on_accounting: ->(facts) { accounting = facts },
|
|
99
|
+
provider_usage: provider_usage
|
|
100
|
+
)
|
|
88
101
|
attempt_id = @id_generator.call
|
|
89
102
|
started_at = Time.now.utc
|
|
90
103
|
emitted = false
|
|
@@ -99,7 +112,9 @@ module AgentHarness
|
|
|
99
112
|
temperature: request[:temperature],
|
|
100
113
|
stream: request[:stream] == true,
|
|
101
114
|
timeout: request[:timeout],
|
|
102
|
-
cancellation: request[:cancellation]
|
|
115
|
+
cancellation: request[:cancellation],
|
|
116
|
+
on_accounting: ->(facts) { accounting = facts },
|
|
117
|
+
prepared_chat: prepared_chat
|
|
103
118
|
) do |event|
|
|
104
119
|
event = normalize_stream_event(event)
|
|
105
120
|
emitted = true if output_event?(event)
|
|
@@ -109,15 +124,21 @@ module AgentHarness
|
|
|
109
124
|
end
|
|
110
125
|
raise RubyLLM::CancelledError unless active?
|
|
111
126
|
|
|
112
|
-
success(candidate, attempt_id, started_at, adapter_result)
|
|
127
|
+
success(candidate, attempt_id, started_at, adapter_result, accounting)
|
|
113
128
|
rescue ObserverError
|
|
114
129
|
raise
|
|
115
130
|
rescue => error
|
|
116
|
-
|
|
131
|
+
return failed_result(classify(error), candidate) unless attempt_id
|
|
132
|
+
|
|
133
|
+
failure(candidate, attempt_id, started_at, classify(error), partial: emitted,
|
|
134
|
+
accounting: accounting || {
|
|
135
|
+
usage: streamed_usage, provider_reported: !streamed_usage.nil?
|
|
136
|
+
})
|
|
117
137
|
end
|
|
118
138
|
|
|
119
|
-
def success(candidate, attempt_id, started_at, adapter_result)
|
|
120
|
-
|
|
139
|
+
def success(candidate, attempt_id, started_at, adapter_result, accounting)
|
|
140
|
+
accounting ||= {usage: adapter_result[:usage], provider_reported: !adapter_result[:usage].nil?}
|
|
141
|
+
append_attempt(candidate, attempt_id, started_at, :succeeded, **accounting)
|
|
121
142
|
result = base_result(candidate).merge(
|
|
122
143
|
status: :succeeded,
|
|
123
144
|
content: adapter_result[:content] || "",
|
|
@@ -130,10 +151,10 @@ module AgentHarness
|
|
|
130
151
|
result
|
|
131
152
|
end
|
|
132
153
|
|
|
133
|
-
def failure(candidate, attempt_id, started_at, error, partial:,
|
|
154
|
+
def failure(candidate, attempt_id, started_at, error, partial:, accounting:)
|
|
134
155
|
status = failure_status(error, partial)
|
|
135
156
|
error = error.merge(retryable: false) if partial
|
|
136
|
-
|
|
157
|
+
append_attempt(candidate, attempt_id, started_at, status, error: error, **accounting)
|
|
137
158
|
result = base_result(candidate).merge(
|
|
138
159
|
status: status,
|
|
139
160
|
content: partial ? @partial_content.dup : "",
|
|
@@ -178,8 +199,9 @@ module AgentHarness
|
|
|
178
199
|
def backoff
|
|
179
200
|
exponent = [attempts.length - 1, 0].max
|
|
180
201
|
delay = retry_config[:base_delay_seconds] * (2**exponent)
|
|
202
|
+
delay = [delay, @last_error[:retry_after_seconds].to_f].max
|
|
181
203
|
cap = retry_config[:max_delay_seconds]
|
|
182
|
-
remaining = cap
|
|
204
|
+
remaining = cap.nil? ? delay : [delay, cap].min
|
|
183
205
|
while remaining.positive? && active?
|
|
184
206
|
interval = [remaining, 0.05].min
|
|
185
207
|
@sleeper.call(interval)
|
|
@@ -227,7 +249,8 @@ module AgentHarness
|
|
|
227
249
|
|
|
228
250
|
def normalize_tool_calls(tool_calls)
|
|
229
251
|
Array(tool_calls).map do |call|
|
|
230
|
-
call.
|
|
252
|
+
id = call[:provider_id].nil? ? @id_generator.call : tool_id(call[:provider_id])
|
|
253
|
+
call.merge(id: id, status: :completed)
|
|
231
254
|
end
|
|
232
255
|
end
|
|
233
256
|
|
|
@@ -265,8 +288,10 @@ module AgentHarness
|
|
|
265
288
|
usage: aggregate_usage, error: error)
|
|
266
289
|
end
|
|
267
290
|
|
|
268
|
-
def
|
|
269
|
-
|
|
291
|
+
def append_attempt(candidate, attempt_id, started_at, status, error: nil, usage: nil, cost: nil,
|
|
292
|
+
provider_reported: false)
|
|
293
|
+
cost = cost.merge(priced_at: Time.now.utc) if cost
|
|
294
|
+
report = AttemptReport.new(
|
|
270
295
|
attempt_id: attempt_id,
|
|
271
296
|
request_id: request[:request_id],
|
|
272
297
|
number: attempts.length + 1,
|
|
@@ -276,20 +301,22 @@ module AgentHarness
|
|
|
276
301
|
started_at: started_at.iso8601(6),
|
|
277
302
|
finished_at: Time.now.utc.iso8601(6),
|
|
278
303
|
usage: usage,
|
|
279
|
-
cost:
|
|
280
|
-
provider_reported:
|
|
304
|
+
cost: cost,
|
|
305
|
+
provider_reported: provider_reported,
|
|
281
306
|
error: error
|
|
282
|
-
|
|
307
|
+
).attributes
|
|
308
|
+
attempts << report
|
|
309
|
+
emit(:attempt_completed, attempt_id:, attempt: report)
|
|
283
310
|
end
|
|
284
311
|
|
|
285
312
|
def aggregate_usage
|
|
286
313
|
reports = attempts.filter_map { |attempt| attempt[:usage] }
|
|
287
314
|
return if reports.empty?
|
|
288
315
|
|
|
289
|
-
%i[input_tokens output_tokens total_tokens].to_h do |key|
|
|
316
|
+
%i[input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens total_tokens].to_h do |key|
|
|
290
317
|
values = reports.filter_map { |usage| usage[key] }
|
|
291
318
|
[key, values.empty? ? nil : values.sum]
|
|
292
|
-
end
|
|
319
|
+
end.compact
|
|
293
320
|
end
|
|
294
321
|
|
|
295
322
|
def classify(error)
|
|
@@ -402,10 +429,23 @@ module AgentHarness
|
|
|
402
429
|
end
|
|
403
430
|
|
|
404
431
|
def self.payload(error, category, code, retryable)
|
|
405
|
-
{category: category, code: code, retryable: retryable, message: safe_message(category, code)}
|
|
432
|
+
payload = {category: category, code: code, retryable: retryable, message: safe_message(category, code)}
|
|
433
|
+
retry_after = retry_after_seconds(error)
|
|
434
|
+
payload[:retry_after_seconds] = retry_after if retry_after
|
|
435
|
+
payload
|
|
406
436
|
end
|
|
407
437
|
private_class_method :payload
|
|
408
438
|
|
|
439
|
+
def self.retry_after_seconds(error)
|
|
440
|
+
return unless error.respond_to?(:response) && error.response
|
|
441
|
+
|
|
442
|
+
headers = error.response.respond_to?(:headers) ? error.response.headers : error.response[:response_headers]
|
|
443
|
+
value = headers&.find { |key, _| key.to_s.casecmp?("retry-after") }&.last
|
|
444
|
+
delay = Float(value, exception: false)
|
|
445
|
+
delay if delay&.finite? && delay >= 0
|
|
446
|
+
end
|
|
447
|
+
private_class_method :retry_after_seconds
|
|
448
|
+
|
|
409
449
|
def self.safe_message(category, code)
|
|
410
450
|
"Chat request failed (#{category}/#{code})"
|
|
411
451
|
end
|
|
@@ -14,18 +14,31 @@ module AgentHarness
|
|
|
14
14
|
anthropic: %i[anthropic_api_key anthropic_api_base],
|
|
15
15
|
openai: %i[openai_api_key openai_api_base]
|
|
16
16
|
}.freeze
|
|
17
|
+
DEFAULT_REQUEST_TIMEOUT = 300
|
|
17
18
|
OPENAI_UNSUPPLIED_CONFIG = %i[openai_organization_id openai_project_id openai_use_system_role].freeze
|
|
18
19
|
|
|
19
|
-
def call(candidate:, messages:, tools:, max_output_tokens:, temperature:, stream:, timeout:, cancellation:,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
response = generate(chat, stream, cancellation
|
|
20
|
+
def call(candidate:, messages:, tools:, max_output_tokens:, temperature:, stream:, timeout:, cancellation:,
|
|
21
|
+
on_accounting: nil, prepared_chat: nil, &on_event)
|
|
22
|
+
provider_usage_reported = false
|
|
23
|
+
chat = prepared_chat || prepare(candidate:, messages:, tools:, max_output_tokens:, temperature:, timeout:,
|
|
24
|
+
on_accounting:, provider_usage: -> { provider_usage_reported })
|
|
25
|
+
response = generate(chat, stream, cancellation) do |event|
|
|
26
|
+
provider_usage_reported = true if event[:type] == :usage_updated
|
|
27
|
+
on_event&.call(event)
|
|
28
|
+
end
|
|
25
29
|
emit_completed_tool_calls(response, &on_event) if stream
|
|
26
30
|
normalize_response(response)
|
|
27
31
|
end
|
|
28
32
|
|
|
33
|
+
def prepare(candidate:, messages:, tools:, max_output_tokens:, temperature:, timeout:, on_accounting: nil,
|
|
34
|
+
provider_usage: -> { false })
|
|
35
|
+
context = build_context(candidate, timeout, on_accounting, provider_usage)
|
|
36
|
+
context.chat(model: candidate[:model], provider: candidate[:provider], protocol: ruby_llm_protocol(candidate),
|
|
37
|
+
assume_model_exists: true).tap do |chat|
|
|
38
|
+
configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
29
42
|
# RubyLLM keys streamed tool-call chunks by a stream index while
|
|
30
43
|
# continuation chunks carry no call id, so correlation state must live
|
|
31
44
|
# between chunks. Also remembers the latest cumulative token counts so
|
|
@@ -69,7 +82,7 @@ module AgentHarness
|
|
|
69
82
|
candidate[:protocol]
|
|
70
83
|
end
|
|
71
84
|
|
|
72
|
-
def build_context(candidate, timeout)
|
|
85
|
+
def build_context(candidate, timeout, on_accounting, provider_usage)
|
|
73
86
|
provider = candidate[:provider].to_sym
|
|
74
87
|
config_keys = PROVIDER_CONFIG[provider]
|
|
75
88
|
raise RubyLLM::ConfigurationError, "Unsupported chat provider: #{provider}" unless config_keys
|
|
@@ -80,10 +93,51 @@ module AgentHarness
|
|
|
80
93
|
config.public_send("#{config_keys[1]}=", candidate[:endpoint])
|
|
81
94
|
clear_unsupplied_openai_config(config) if provider == :openai
|
|
82
95
|
config.max_retries = 0
|
|
96
|
+
config.instrumenter = UsageInstrumenter.new(on_accounting, provider_usage) if on_accounting
|
|
83
97
|
apply_timeout(config, timeout)
|
|
84
98
|
end
|
|
85
99
|
end
|
|
86
100
|
|
|
101
|
+
# Retains only accounting instrumentation; other events may contain
|
|
102
|
+
# request content and are intentionally discarded.
|
|
103
|
+
class UsageInstrumenter
|
|
104
|
+
def initialize(callback, provider_usage)
|
|
105
|
+
@callback = callback
|
|
106
|
+
@provider_usage = provider_usage
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def instrument(name, payload)
|
|
110
|
+
@callback.call(normalize(payload)) if name == "usage.ruby_llm"
|
|
111
|
+
yield(payload) if block_given?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def normalize(payload)
|
|
117
|
+
tokens = payload.fetch(:tokens)
|
|
118
|
+
cost = payload.fetch(:cost)
|
|
119
|
+
usage = tokens.to_h
|
|
120
|
+
usage[:total_tokens] = tokens.input + tokens.output if tokens.input && tokens.output
|
|
121
|
+
{
|
|
122
|
+
usage: usage,
|
|
123
|
+
cost: cost.total.nil? ? nil : cost.to_h.merge(source: cost_source(tokens), currency: "USD"),
|
|
124
|
+
provider_reported: provider_reported?(payload, usage)
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def provider_reported?(payload, usage)
|
|
129
|
+
return false if usage.empty?
|
|
130
|
+
return true unless payload[:status]&.to_sym == :failed
|
|
131
|
+
return true if @provider_usage.call
|
|
132
|
+
|
|
133
|
+
usage.values.compact.any?(&:positive?)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def cost_source(tokens)
|
|
137
|
+
tokens.reported_cost.nil? ? :estimated : :provider_reported
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
87
141
|
def clear_unsupplied_openai_config(config)
|
|
88
142
|
OPENAI_UNSUPPLIED_CONFIG.each { |key| config.public_send("#{key}=", nil) }
|
|
89
143
|
end
|
|
@@ -93,8 +147,7 @@ module AgentHarness
|
|
|
93
147
|
raise UnsupportedOptionError, "RubyLLM does not support request-local connect timeouts"
|
|
94
148
|
end
|
|
95
149
|
|
|
96
|
-
|
|
97
|
-
config.request_timeout = seconds if seconds
|
|
150
|
+
config.request_timeout = timeout&.dig(:read_seconds) || DEFAULT_REQUEST_TIMEOUT
|
|
98
151
|
end
|
|
99
152
|
|
|
100
153
|
def configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature)
|
|
@@ -262,11 +315,9 @@ module AgentHarness
|
|
|
262
315
|
end
|
|
263
316
|
|
|
264
317
|
def normalize_usage(tokens)
|
|
265
|
-
|
|
266
|
-
output = tokens&.output
|
|
267
|
-
return unless input || output
|
|
318
|
+
return unless tokens&.to_h&.any?
|
|
268
319
|
|
|
269
|
-
|
|
320
|
+
tokens.to_h.merge(total_tokens: (tokens.input && tokens.output) ? tokens.input + tokens.output : nil)
|
|
270
321
|
end
|
|
271
322
|
end
|
|
272
323
|
end
|
data/lib/agent_harness.rb
CHANGED
|
@@ -471,6 +471,7 @@ 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"
|
|
475
476
|
require_relative "agent_harness/quota_status"
|
|
476
477
|
require_relative "agent_harness/authentication"
|
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.42.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bart Agapinan
|
|
@@ -135,6 +135,7 @@ files:
|
|
|
135
135
|
- json-2.18.1.gem
|
|
136
136
|
- lib/agent-harness.rb
|
|
137
137
|
- lib/agent_harness.rb
|
|
138
|
+
- lib/agent_harness/api/attempt_report.rb
|
|
138
139
|
- lib/agent_harness/api/chat_transport.rb
|
|
139
140
|
- lib/agent_harness/api/ruby_llm_chat_adapter.rb
|
|
140
141
|
- lib/agent_harness/authentication.rb
|