agent-harness 0.40.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 +14 -0
- data/docs/provider-neutral-api-execution-contract.md +109 -7
- data/lib/agent_harness/api/attempt_report.rb +89 -0
- data/lib/agent_harness/api/chat_transport.rb +455 -0
- data/lib/agent_harness/api/ruby_llm_chat_adapter.rb +324 -0
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +2 -0
- metadata +4 -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,20 @@
|
|
|
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
|
+
|
|
15
|
+
## [0.41.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.40.0...agent-harness/v0.41.0) (2026-09-25)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* Normalize API Chat Transport, Tools and Streaming (RDR-072) ([#441](https://github.com/viamin/agent-harness/issues/441)) ([761c118](https://github.com/viamin/agent-harness/commit/761c11842ccacd18d91e9412f71cb98d76afa3b2))
|
|
21
|
+
|
|
8
22
|
## [0.40.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.39.0...agent-harness/v0.40.0) (2026-09-25)
|
|
9
23
|
|
|
10
24
|
|
|
@@ -7,19 +7,83 @@ usage, and optional conversation-persistence work.
|
|
|
7
7
|
|
|
8
8
|
## Status and rollout boundary
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
contract tests, implementation, release evidence, and
|
|
14
|
-
evidence before a caller enables it.
|
|
10
|
+
RDR-072's rollout guard was **docs-only** for the design phase. The normalized
|
|
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
|
+
its own failing-first contract tests, implementation, release evidence, and
|
|
14
|
+
downstream adoption evidence before a caller enables it.
|
|
15
15
|
|
|
16
16
|
Existing CLI and subscription behavior remains the default. Existing
|
|
17
17
|
`TextTransport`, `OpenAICompatibleTransport`, `Conversation`, and `Response`
|
|
18
|
-
interfaces remain available
|
|
19
|
-
caller must not infer support
|
|
18
|
+
interfaces remain available and unchanged; they are not aliases for the
|
|
19
|
+
normalized API. A caller must not infer support for another capability from a
|
|
20
|
+
gem version or issue closure.
|
|
20
21
|
|
|
21
22
|
Normative words such as MUST and MUST NOT describe the future public boundary.
|
|
22
23
|
|
|
24
|
+
## Shipped normalized chat surface
|
|
25
|
+
|
|
26
|
+
`AgentHarness::Api::ChatTransport#call` implements one normalized assistant
|
|
27
|
+
response while leaving the conversation loop and all application tool
|
|
28
|
+
execution with the caller:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
transport = AgentHarness::Api::ChatTransport.new
|
|
32
|
+
result = transport.call(request.merge(
|
|
33
|
+
operation: :chat,
|
|
34
|
+
messages: [
|
|
35
|
+
{id: "system-1", role: :system,
|
|
36
|
+
content: [{type: :text, text: "Be concise"}]},
|
|
37
|
+
{id: "user-1", role: :user,
|
|
38
|
+
content: [{type: :text, text: "Summarize this"}]}
|
|
39
|
+
],
|
|
40
|
+
tools: [{
|
|
41
|
+
name: "lookup",
|
|
42
|
+
description: "Looks up a record",
|
|
43
|
+
input_schema: {type: "object", properties: {id: {type: "string"}}}
|
|
44
|
+
}],
|
|
45
|
+
max_output_tokens: 1_000,
|
|
46
|
+
stream: true
|
|
47
|
+
), observer: ->(event) { events << event })
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The returned value is the normalized result hash in this document. The
|
|
51
|
+
observer is either callable or responds to `on_chat_event`. It receives ordered
|
|
52
|
+
events with request/attempt identity and sequence numbers. This transport never
|
|
53
|
+
invokes a supplied tool; callers append completed tool results to a later
|
|
54
|
+
request. A failed partial stream is terminal, so its content cannot be appended
|
|
55
|
+
to a fallback response and its tool calls cannot be replayed automatically.
|
|
56
|
+
|
|
57
|
+
Streamed tool-call arguments are emitted as raw, appendable JSON fragments
|
|
58
|
+
correlated to one `tool_call_started` event per provider call, and cumulative
|
|
59
|
+
provider token counts are emitted as deduplicated `usage_updated` events. An
|
|
60
|
+
observer that raises aborts the in-flight request and surfaces as
|
|
61
|
+
`AgentHarness::Api::ChatTransport::ObserverError` with the original failure as
|
|
62
|
+
its `cause`; it is never classified as a provider error, never retried, and the
|
|
63
|
+
failed observer is not invoked again.
|
|
64
|
+
|
|
65
|
+
The verified scopes are Anthropic with `protocol: :messages`, OpenAI with
|
|
66
|
+
`protocol: :responses` or `:chat_completions`, and OpenAI-compatible endpoints
|
|
67
|
+
with `provider: :openai`, an explicit `endpoint`, and
|
|
68
|
+
`protocol: :chat_completions`. Compatible endpoints that do not implement the
|
|
69
|
+
Responses API must select `:chat_completions`; the transport never probes and
|
|
70
|
+
silently switches protocols. Only `authentication_mode: :api_key` is currently
|
|
71
|
+
supported.
|
|
72
|
+
|
|
73
|
+
Credentials, endpoint, custom headers, timeout, and RubyLLM configuration are
|
|
74
|
+
isolated with a request-local `RubyLLM::Context`. RubyLLM middleware retries
|
|
75
|
+
are disabled; `retry.max_attempts` is the total physical-attempt limit owned by
|
|
76
|
+
the harness. Authentication headers cannot be overridden by custom headers.
|
|
77
|
+
`max_output_tokens` is forwarded without changing it.
|
|
78
|
+
|
|
79
|
+
Unknown model IDs are allowed only because a complete provider and protocol
|
|
80
|
+
are explicit in every candidate (`assume_model_exists: true` in the RubyLLM
|
|
81
|
+
adapter). This skips registry validation; it does not assert that the endpoint
|
|
82
|
+
supports the model. Provider rejection returns a classified failure. Custom
|
|
83
|
+
endpoints retain the selected provider's wire protocol and authentication
|
|
84
|
+
shape. Custom provider types, authentication modes, media content, and
|
|
85
|
+
automatic protocol discovery are not supported by this capability.
|
|
86
|
+
|
|
23
87
|
## Ownership boundary
|
|
24
88
|
|
|
25
89
|
AgentHarness owns protocol translation and one bounded provider request:
|
|
@@ -272,6 +336,23 @@ observer so durable accounting can persist an attempt even when no message is
|
|
|
272
336
|
created. Callers deduplicate on `attempt_id`. Cost identifies its source as
|
|
273
337
|
provider-reported or harness-estimated; unknown cost remains `nil`.
|
|
274
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
|
+
|
|
275
356
|
Only errors classified `transient` are eligible for bounded request retry:
|
|
276
357
|
connection failure, timeout before a partial stream, rate limit, server error,
|
|
277
358
|
service unavailable, and overload. Authentication, authorization, billing,
|
|
@@ -505,6 +586,27 @@ types stay behind the harness boundary.
|
|
|
505
586
|
|
|
506
587
|
## Compatibility and release evidence
|
|
507
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
|
+
|
|
508
610
|
AgentHarness currently supports Ruby 3.2 and later and must remain usable as a
|
|
509
611
|
plain Ruby gem. RubyLLM 2.0.0 itself supports Ruby 3.1.3 and later, but adding it
|
|
510
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
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "ruby_llm"
|
|
5
|
+
require_relative "attempt_report"
|
|
6
|
+
require_relative "ruby_llm_chat_adapter"
|
|
7
|
+
|
|
8
|
+
module AgentHarness
|
|
9
|
+
module Api
|
|
10
|
+
# Executes one normalized chat response without running application tools.
|
|
11
|
+
class ChatTransport
|
|
12
|
+
RESERVED_HEADERS = {
|
|
13
|
+
anthropic: %w[x-api-key anthropic-version],
|
|
14
|
+
openai: %w[authorization]
|
|
15
|
+
}.freeze
|
|
16
|
+
PROTOCOLS = {
|
|
17
|
+
anthropic: %i[messages],
|
|
18
|
+
openai: %i[responses chat_completions]
|
|
19
|
+
}.freeze
|
|
20
|
+
DEFAULT_RETRY = {max_attempts: 1, base_delay_seconds: 0, max_delay_seconds: 0}.freeze
|
|
21
|
+
|
|
22
|
+
# Raised when the caller's observer fails. Observer bugs stay outside
|
|
23
|
+
# provider error classification, abort the in-flight request, and are
|
|
24
|
+
# never re-invoked with a synthetic terminal event.
|
|
25
|
+
class ObserverError < StandardError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(adapter: RubyLlmChatAdapter.new, id_generator: -> { SecureRandom.uuid }, sleeper: Kernel.method(:sleep))
|
|
29
|
+
@adapter = adapter
|
|
30
|
+
@id_generator = id_generator
|
|
31
|
+
@sleeper = sleeper
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(request, observer: nil, &on_event)
|
|
35
|
+
execution = Execution.new(request, adapter: @adapter, id_generator: @id_generator,
|
|
36
|
+
sleeper: @sleeper, observer: observer || on_event)
|
|
37
|
+
execution.call
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Holds mutable state for exactly one request, keeping the transport reusable.
|
|
41
|
+
class Execution
|
|
42
|
+
def initialize(request, adapter:, id_generator:, sleeper:, observer:)
|
|
43
|
+
@request = symbolize(request)
|
|
44
|
+
@adapter = adapter
|
|
45
|
+
@id_generator = id_generator
|
|
46
|
+
@sleeper = sleeper
|
|
47
|
+
@observer = observer
|
|
48
|
+
@attempts = []
|
|
49
|
+
@sequence = 0
|
|
50
|
+
@partial_content = +""
|
|
51
|
+
@stream_tool_calls = {}
|
|
52
|
+
@tool_ids = {}
|
|
53
|
+
validate!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def call
|
|
57
|
+
return cancelled_result unless active?
|
|
58
|
+
|
|
59
|
+
candidates.each_with_index do |candidate, candidate_index|
|
|
60
|
+
result = attempt_candidate(candidate, candidate_index)
|
|
61
|
+
return result if terminal?(result)
|
|
62
|
+
end
|
|
63
|
+
failed_result(@last_error, @last_candidate)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
attr_reader :request, :attempts
|
|
69
|
+
|
|
70
|
+
def attempt_candidate(candidate, candidate_index)
|
|
71
|
+
loop do
|
|
72
|
+
return cancelled_result(candidate) unless active?
|
|
73
|
+
return failed_result(@last_error, candidate) if attempts.length >= retry_config[:max_attempts]
|
|
74
|
+
|
|
75
|
+
result = perform_attempt(candidate)
|
|
76
|
+
return result if result[:status] == :succeeded || result[:status] == :partial
|
|
77
|
+
return cancelled_result(candidate) unless active?
|
|
78
|
+
|
|
79
|
+
@last_error = result[:error]
|
|
80
|
+
@last_candidate = candidate
|
|
81
|
+
return result if select_fallback(result, candidate_index)
|
|
82
|
+
return result unless retryable?(result)
|
|
83
|
+
|
|
84
|
+
backoff
|
|
85
|
+
end
|
|
86
|
+
end
|
|
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
|
+
)
|
|
101
|
+
attempt_id = @id_generator.call
|
|
102
|
+
started_at = Time.now.utc
|
|
103
|
+
emitted = false
|
|
104
|
+
streamed_usage = nil
|
|
105
|
+
emit(:response_started, attempt_id:, candidate: candidate_identity(candidate))
|
|
106
|
+
|
|
107
|
+
adapter_result = @adapter.call(
|
|
108
|
+
candidate: candidate,
|
|
109
|
+
messages: request[:messages],
|
|
110
|
+
tools: request[:tools] || [],
|
|
111
|
+
max_output_tokens: request[:max_output_tokens],
|
|
112
|
+
temperature: request[:temperature],
|
|
113
|
+
stream: request[:stream] == true,
|
|
114
|
+
timeout: request[:timeout],
|
|
115
|
+
cancellation: request[:cancellation],
|
|
116
|
+
on_accounting: ->(facts) { accounting = facts },
|
|
117
|
+
prepared_chat: prepared_chat
|
|
118
|
+
) do |event|
|
|
119
|
+
event = normalize_stream_event(event)
|
|
120
|
+
emitted = true if output_event?(event)
|
|
121
|
+
streamed_usage = event.except(:type) if event[:type] == :usage_updated
|
|
122
|
+
accumulate(event)
|
|
123
|
+
emit(event.fetch(:type), attempt_id:, **event.except(:type))
|
|
124
|
+
end
|
|
125
|
+
raise RubyLLM::CancelledError unless active?
|
|
126
|
+
|
|
127
|
+
success(candidate, attempt_id, started_at, adapter_result, accounting)
|
|
128
|
+
rescue ObserverError
|
|
129
|
+
raise
|
|
130
|
+
rescue => error
|
|
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
|
+
})
|
|
137
|
+
end
|
|
138
|
+
|
|
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)
|
|
142
|
+
result = base_result(candidate).merge(
|
|
143
|
+
status: :succeeded,
|
|
144
|
+
content: adapter_result[:content] || "",
|
|
145
|
+
tool_calls: normalize_tool_calls(adapter_result[:tool_calls]),
|
|
146
|
+
finish_reason: adapter_result[:finish_reason],
|
|
147
|
+
usage: aggregate_usage,
|
|
148
|
+
error: nil
|
|
149
|
+
)
|
|
150
|
+
emit(:response_completed, attempt_id:, result: result)
|
|
151
|
+
result
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def failure(candidate, attempt_id, started_at, error, partial:, accounting:)
|
|
155
|
+
status = failure_status(error, partial)
|
|
156
|
+
error = error.merge(retryable: false) if partial
|
|
157
|
+
append_attempt(candidate, attempt_id, started_at, status, error: error, **accounting)
|
|
158
|
+
result = base_result(candidate).merge(
|
|
159
|
+
status: status,
|
|
160
|
+
content: partial ? @partial_content.dup : "",
|
|
161
|
+
tool_calls: partial_tool_calls,
|
|
162
|
+
finish_reason: nil,
|
|
163
|
+
usage: aggregate_usage,
|
|
164
|
+
error: error
|
|
165
|
+
)
|
|
166
|
+
emit((status == :cancelled) ? :response_cancelled : :response_failed, attempt_id:, result: result)
|
|
167
|
+
result
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def fallback_allowed?(result, candidate_index)
|
|
171
|
+
return false if result[:status] == :partial
|
|
172
|
+
return false unless fallback_categories.include?(result.dig(:error, :category))
|
|
173
|
+
return false unless candidates[candidate_index + 1]
|
|
174
|
+
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def select_fallback(result, candidate_index)
|
|
179
|
+
return false unless fallback_allowed?(result, candidate_index)
|
|
180
|
+
|
|
181
|
+
next_candidate = candidates[candidate_index + 1]
|
|
182
|
+
emit(:fallback_selected, attempt_id: attempts.last[:attempt_id],
|
|
183
|
+
from: candidate_identity(candidates[candidate_index]),
|
|
184
|
+
to: candidate_identity(next_candidate), error: result[:error])
|
|
185
|
+
true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def retryable?(result)
|
|
189
|
+
result.dig(:error, :retryable) && attempts.length < retry_config[:max_attempts]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def terminal?(result)
|
|
193
|
+
return true if %i[succeeded partial cancelled].include?(result[:status])
|
|
194
|
+
return true unless fallback_categories.include?(result.dig(:error, :category))
|
|
195
|
+
|
|
196
|
+
attempts.length >= retry_config[:max_attempts]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def backoff
|
|
200
|
+
exponent = [attempts.length - 1, 0].max
|
|
201
|
+
delay = retry_config[:base_delay_seconds] * (2**exponent)
|
|
202
|
+
delay = [delay, @last_error[:retry_after_seconds].to_f].max
|
|
203
|
+
cap = retry_config[:max_delay_seconds]
|
|
204
|
+
remaining = cap.nil? ? delay : [delay, cap].min
|
|
205
|
+
while remaining.positive? && active?
|
|
206
|
+
interval = [remaining, 0.05].min
|
|
207
|
+
@sleeper.call(interval)
|
|
208
|
+
remaining -= interval
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def emit(type, attempt_id:, **payload)
|
|
213
|
+
@sequence += 1
|
|
214
|
+
event = payload.merge(type: type, request_id: request[:request_id], attempt_id: attempt_id, sequence: @sequence)
|
|
215
|
+
deliver(event) if @observer
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Observer exceptions are caller bugs, not provider failures: they
|
|
219
|
+
# abort the in-flight request and surface directly instead of being
|
|
220
|
+
# classified or retried as provider errors.
|
|
221
|
+
def deliver(event)
|
|
222
|
+
@observer.respond_to?(:on_chat_event) ? @observer.on_chat_event(event) : @observer.call(event)
|
|
223
|
+
rescue => error
|
|
224
|
+
raise ObserverError, "chat observer failed: #{error.class} #{error.message}", error.backtrace
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def accumulate(event)
|
|
228
|
+
@partial_content << event[:content].to_s if event[:type] == :text_delta
|
|
229
|
+
return unless event[:id]
|
|
230
|
+
|
|
231
|
+
call = (@stream_tool_calls[event[:id]] ||= {
|
|
232
|
+
id: event[:id], provider_id: event[:provider_id], name: event[:name],
|
|
233
|
+
arguments_json: +"", status: :incomplete
|
|
234
|
+
})
|
|
235
|
+
call[:arguments_json] << event[:arguments_json].to_s if event[:type] == :tool_call_delta
|
|
236
|
+
if event[:type] == :tool_call_completed
|
|
237
|
+
call[:arguments_json] = event[:arguments_json]
|
|
238
|
+
call[:status] = :completed
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def output_event?(event)
|
|
243
|
+
%i[text_delta tool_call_started tool_call_delta tool_call_completed].include?(event[:type])
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def partial_tool_calls
|
|
247
|
+
@stream_tool_calls.values.map(&:dup)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def normalize_tool_calls(tool_calls)
|
|
251
|
+
Array(tool_calls).map do |call|
|
|
252
|
+
id = call[:provider_id].nil? ? @id_generator.call : tool_id(call[:provider_id])
|
|
253
|
+
call.merge(id: id, status: :completed)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def normalize_stream_event(event)
|
|
258
|
+
return event unless event[:provider_id]
|
|
259
|
+
|
|
260
|
+
event.merge(id: tool_id(event[:provider_id]))
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def tool_id(provider_id)
|
|
264
|
+
@tool_ids[provider_id] ||= @id_generator.call
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def base_result(candidate)
|
|
268
|
+
{
|
|
269
|
+
request_id: request[:request_id],
|
|
270
|
+
provider: candidate&.dig(:provider),
|
|
271
|
+
model: candidate&.dig(:model),
|
|
272
|
+
protocol: candidate&.dig(:protocol),
|
|
273
|
+
authentication_mode: candidate&.dig(:authentication_mode),
|
|
274
|
+
parsed: nil,
|
|
275
|
+
attempts: attempts.dup,
|
|
276
|
+
provider_request_id: nil
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def failed_result(error, candidate)
|
|
281
|
+
base_result(candidate).merge(status: :failed, content: "", tool_calls: [], finish_reason: nil,
|
|
282
|
+
usage: aggregate_usage, error: error)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def cancelled_result(candidate = candidates.first)
|
|
286
|
+
error = {category: :cancelled, code: :cancelled, retryable: false, message: "Request cancelled"}
|
|
287
|
+
base_result(candidate).merge(status: :cancelled, content: "", tool_calls: [], finish_reason: nil,
|
|
288
|
+
usage: aggregate_usage, error: error)
|
|
289
|
+
end
|
|
290
|
+
|
|
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(
|
|
295
|
+
attempt_id: attempt_id,
|
|
296
|
+
request_id: request[:request_id],
|
|
297
|
+
number: attempts.length + 1,
|
|
298
|
+
provider: candidate[:provider],
|
|
299
|
+
model: candidate[:model],
|
|
300
|
+
status: status,
|
|
301
|
+
started_at: started_at.iso8601(6),
|
|
302
|
+
finished_at: Time.now.utc.iso8601(6),
|
|
303
|
+
usage: usage,
|
|
304
|
+
cost: cost,
|
|
305
|
+
provider_reported: provider_reported,
|
|
306
|
+
error: error
|
|
307
|
+
).attributes
|
|
308
|
+
attempts << report
|
|
309
|
+
emit(:attempt_completed, attempt_id:, attempt: report)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def aggregate_usage
|
|
313
|
+
reports = attempts.filter_map { |attempt| attempt[:usage] }
|
|
314
|
+
return if reports.empty?
|
|
315
|
+
|
|
316
|
+
%i[input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens total_tokens].to_h do |key|
|
|
317
|
+
values = reports.filter_map { |usage| usage[key] }
|
|
318
|
+
[key, values.empty? ? nil : values.sum]
|
|
319
|
+
end.compact
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def classify(error)
|
|
323
|
+
ErrorClassifier.call(error)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def failure_status(error, partial)
|
|
327
|
+
return :cancelled if error[:category] == :cancelled
|
|
328
|
+
return :partial if partial
|
|
329
|
+
|
|
330
|
+
:failed
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def candidates = request[:candidates]
|
|
334
|
+
def retry_config = @retry_config ||= DEFAULT_RETRY.merge(request[:retry] || {})
|
|
335
|
+
def fallback_categories = Array(request.dig(:fallback, :on_error_categories)).map(&:to_sym)
|
|
336
|
+
|
|
337
|
+
def active?
|
|
338
|
+
token = request[:cancellation]
|
|
339
|
+
return true unless token
|
|
340
|
+
|
|
341
|
+
cancelled = token.respond_to?(:cancelled?) ? token.cancelled? : token.call
|
|
342
|
+
!cancelled
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def validate!
|
|
346
|
+
raise ArgumentError, "operation must be :chat" unless request[:operation]&.to_sym == :chat
|
|
347
|
+
raise ArgumentError, "request_id is required" if request[:request_id].to_s.empty?
|
|
348
|
+
raise ArgumentError, "candidates must not be empty" if !request[:candidates].is_a?(Array) || request[:candidates].empty?
|
|
349
|
+
raise ArgumentError, "messages must be an array" unless request[:messages].is_a?(Array)
|
|
350
|
+
validate_attempt_limit!
|
|
351
|
+
candidates.each { |candidate| validate_candidate!(candidate) }
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def validate_attempt_limit!
|
|
355
|
+
limit = retry_config[:max_attempts]
|
|
356
|
+
raise ArgumentError, "retry.max_attempts must be a positive integer" unless limit.is_a?(Integer) && limit.positive?
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def validate_candidate!(candidate)
|
|
360
|
+
candidate.replace(symbolize(candidate))
|
|
361
|
+
%i[provider model protocol authentication_mode credentials].each do |key|
|
|
362
|
+
raise ArgumentError, "candidate.#{key} is required" if candidate[key].nil?
|
|
363
|
+
end
|
|
364
|
+
raise ArgumentError, "only api_key authentication is supported" unless candidate[:authentication_mode].to_sym == :api_key
|
|
365
|
+
supported_protocols = PROTOCOLS[candidate[:provider].to_sym]
|
|
366
|
+
unless supported_protocols&.include?(candidate[:protocol].to_sym)
|
|
367
|
+
raise ArgumentError, "unsupported provider/protocol combination"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
headers = candidate[:headers] || {}
|
|
371
|
+
reserved_names = RESERVED_HEADERS.fetch(candidate[:provider].to_sym, RESERVED_HEADERS.values.flatten)
|
|
372
|
+
reserved = headers.keys.map { |key| key.to_s.downcase } & reserved_names
|
|
373
|
+
raise ArgumentError, "reserved header override: #{reserved.first}" if reserved.any?
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def candidate_identity(candidate)
|
|
377
|
+
candidate.slice(:provider, :model, :protocol, :authentication_mode)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def symbolize(value)
|
|
381
|
+
if value.is_a?(Hash)
|
|
382
|
+
return value.each_with_object({}) do |(key, child), normalized|
|
|
383
|
+
symbol = key.to_sym
|
|
384
|
+
normalized[symbol] = (symbol == :headers) ? child.dup : symbolize(child)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
return value.map { |child| symbolize(child) } if value.is_a?(Array)
|
|
388
|
+
|
|
389
|
+
value
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
module ErrorClassifier
|
|
395
|
+
TRANSIENT = {
|
|
396
|
+
Faraday::TimeoutError => :timeout,
|
|
397
|
+
Faraday::ConnectionFailed => :connection_failed,
|
|
398
|
+
Faraday::SSLError => :connection_failed,
|
|
399
|
+
RubyLLM::RateLimitError => :rate_limited,
|
|
400
|
+
RubyLLM::ServerError => :server_error,
|
|
401
|
+
RubyLLM::ServiceUnavailableError => :service_unavailable,
|
|
402
|
+
RubyLLM::OverloadedError => :overloaded
|
|
403
|
+
}.freeze
|
|
404
|
+
|
|
405
|
+
NON_RETRYABLE = {
|
|
406
|
+
RubyLlmChatAdapter::MissingCredentialError => [:authentication, :invalid_credential],
|
|
407
|
+
RubyLLM::UnauthorizedError => [:authentication, :invalid_credential],
|
|
408
|
+
RubyLLM::ForbiddenError => [:authorization, :permission_denied],
|
|
409
|
+
RubyLLM::PaymentRequiredError => [:billing, :billing_unavailable],
|
|
410
|
+
RubyLLM::ContextLengthExceededError => [:context_length, :context_length_exceeded],
|
|
411
|
+
RubyLLM::BadRequestError => [:invalid_request, :invalid_request],
|
|
412
|
+
RubyLlmChatAdapter::UnsupportedOptionError => [:unsupported, :unsupported_capability],
|
|
413
|
+
RubyLLM::UnsupportedServerToolError => [:unsupported, :unsupported_capability],
|
|
414
|
+
RubyLLM::ToolCallParseError => [:invalid_response, :invalid_tool_arguments],
|
|
415
|
+
JSON::ParserError => [:invalid_response, :invalid_tool_arguments],
|
|
416
|
+
RubyLLM::ModelNotFoundError => [:configuration, :invalid_configuration],
|
|
417
|
+
RubyLLM::ConfigurationError => [:configuration, :invalid_configuration],
|
|
418
|
+
RubyLLM::CancelledError => [:cancelled, :cancelled]
|
|
419
|
+
}.freeze
|
|
420
|
+
|
|
421
|
+
def self.call(error)
|
|
422
|
+
transient = TRANSIENT.find { |klass, _| error.is_a?(klass) }
|
|
423
|
+
return payload(error, :transient, transient.last, true) if transient
|
|
424
|
+
|
|
425
|
+
permanent = NON_RETRYABLE.find { |klass, _| error.is_a?(klass) }
|
|
426
|
+
return payload(error, *permanent.last, false) if permanent
|
|
427
|
+
|
|
428
|
+
payload(error, :unknown, :unclassified_provider_error, false)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def self.payload(error, category, code, retryable)
|
|
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
|
|
436
|
+
end
|
|
437
|
+
private_class_method :payload
|
|
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
|
+
|
|
449
|
+
def self.safe_message(category, code)
|
|
450
|
+
"Chat request failed (#{category}/#{code})"
|
|
451
|
+
end
|
|
452
|
+
private_class_method :safe_message
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ruby_llm"
|
|
5
|
+
|
|
6
|
+
module AgentHarness
|
|
7
|
+
module Api
|
|
8
|
+
# Translates the normalized public chat values to RubyLLM public objects.
|
|
9
|
+
class RubyLlmChatAdapter
|
|
10
|
+
class UnsupportedOptionError < StandardError; end
|
|
11
|
+
class MissingCredentialError < StandardError; end
|
|
12
|
+
|
|
13
|
+
PROVIDER_CONFIG = {
|
|
14
|
+
anthropic: %i[anthropic_api_key anthropic_api_base],
|
|
15
|
+
openai: %i[openai_api_key openai_api_base]
|
|
16
|
+
}.freeze
|
|
17
|
+
DEFAULT_REQUEST_TIMEOUT = 300
|
|
18
|
+
OPENAI_UNSUPPLIED_CONFIG = %i[openai_organization_id openai_project_id openai_use_system_role].freeze
|
|
19
|
+
|
|
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
|
|
29
|
+
emit_completed_tool_calls(response, &on_event) if stream
|
|
30
|
+
normalize_response(response)
|
|
31
|
+
end
|
|
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
|
+
|
|
42
|
+
# RubyLLM keys streamed tool-call chunks by a stream index while
|
|
43
|
+
# continuation chunks carry no call id, so correlation state must live
|
|
44
|
+
# between chunks. Also remembers the latest cumulative token counts so
|
|
45
|
+
# duplicate cumulative reports are not re-emitted.
|
|
46
|
+
class StreamState
|
|
47
|
+
attr_accessor :input_tokens, :output_tokens
|
|
48
|
+
|
|
49
|
+
def initialize
|
|
50
|
+
@provider_id_by_key = {}
|
|
51
|
+
@started_ids = {}
|
|
52
|
+
@latest_provider_id = nil
|
|
53
|
+
@input_tokens = nil
|
|
54
|
+
@output_tokens = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Links a stream chunk key to its provider call id, returning true
|
|
58
|
+
# only the first time +provider_id+ is seen.
|
|
59
|
+
def start(stream_key, provider_id)
|
|
60
|
+
@provider_id_by_key[stream_key] = provider_id unless stream_key.nil?
|
|
61
|
+
@latest_provider_id = provider_id
|
|
62
|
+
return false if @started_ids.key?(provider_id)
|
|
63
|
+
|
|
64
|
+
@started_ids[provider_id] = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def provider_id(stream_key)
|
|
68
|
+
stream_key.nil? ? @latest_provider_id : @provider_id_by_key[stream_key]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def usage
|
|
72
|
+
total = (input_tokens + output_tokens) if input_tokens && output_tokens
|
|
73
|
+
{input_tokens: input_tokens, output_tokens: output_tokens, total_tokens: total}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def ruby_llm_protocol(candidate)
|
|
80
|
+
return :anthropic if candidate[:provider].to_sym == :anthropic && candidate[:protocol].to_sym == :messages
|
|
81
|
+
|
|
82
|
+
candidate[:protocol]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_context(candidate, timeout, on_accounting, provider_usage)
|
|
86
|
+
provider = candidate[:provider].to_sym
|
|
87
|
+
config_keys = PROVIDER_CONFIG[provider]
|
|
88
|
+
raise RubyLLM::ConfigurationError, "Unsupported chat provider: #{provider}" unless config_keys
|
|
89
|
+
raise MissingCredentialError, "API key is required" if candidate.dig(:credentials, :api_key).to_s.empty?
|
|
90
|
+
|
|
91
|
+
RubyLLM.context do |config|
|
|
92
|
+
config.public_send("#{config_keys[0]}=", candidate.dig(:credentials, :api_key))
|
|
93
|
+
config.public_send("#{config_keys[1]}=", candidate[:endpoint])
|
|
94
|
+
clear_unsupplied_openai_config(config) if provider == :openai
|
|
95
|
+
config.max_retries = 0
|
|
96
|
+
config.instrumenter = UsageInstrumenter.new(on_accounting, provider_usage) if on_accounting
|
|
97
|
+
apply_timeout(config, timeout)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
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
|
+
|
|
141
|
+
def clear_unsupplied_openai_config(config)
|
|
142
|
+
OPENAI_UNSUPPLIED_CONFIG.each { |key| config.public_send("#{key}=", nil) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def apply_timeout(config, timeout)
|
|
146
|
+
if timeout&.dig(:connect_seconds)
|
|
147
|
+
raise UnsupportedOptionError, "RubyLLM does not support request-local connect timeouts"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
config.request_timeout = timeout&.dig(:read_seconds) || DEFAULT_REQUEST_TIMEOUT
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature)
|
|
154
|
+
chat.messages = normalize_messages(messages)
|
|
155
|
+
chat.with_tools(tools.map { |tool| normalized_tool(tool) }) unless tools.empty?
|
|
156
|
+
chat.with_headers(candidate[:headers] || {})
|
|
157
|
+
chat.with_max_output_tokens(max_output_tokens) if max_output_tokens
|
|
158
|
+
chat.with_temperature(temperature) unless temperature.nil?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def generate(chat, stream, cancellation)
|
|
162
|
+
return generate_without_events(chat, cancellation) unless stream
|
|
163
|
+
|
|
164
|
+
state = StreamState.new
|
|
165
|
+
chat.generate do |chunk|
|
|
166
|
+
if cancelled?(cancellation)
|
|
167
|
+
chat.cancel
|
|
168
|
+
raise RubyLLM::CancelledError
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
stream_events(chunk, state).each { |event| yield event }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def generate_without_events(chat, cancellation)
|
|
176
|
+
raise RubyLLM::CancelledError if cancelled?(cancellation)
|
|
177
|
+
|
|
178
|
+
chat.generate
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def cancelled?(token)
|
|
182
|
+
return false unless token
|
|
183
|
+
|
|
184
|
+
token.respond_to?(:cancelled?) ? token.cancelled? : token.call
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def normalize_messages(messages)
|
|
188
|
+
stable_to_provider = tool_id_map(messages)
|
|
189
|
+
messages.map do |message|
|
|
190
|
+
role = message.fetch(:role).to_sym
|
|
191
|
+
normalized = {role: role, content: text_content(message[:content])}
|
|
192
|
+
normalized[:tool_calls] = normalize_input_tool_calls(message[:tool_calls]) if message[:tool_calls]
|
|
193
|
+
normalized[:tool_call_id] = stable_to_provider.fetch(message[:tool_call_id], message[:tool_call_id]) if role == :tool
|
|
194
|
+
normalized
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def tool_id_map(messages)
|
|
199
|
+
messages.each_with_object({}) do |message, ids|
|
|
200
|
+
Array(message[:tool_calls]).each do |call|
|
|
201
|
+
ids[call[:id]] = call[:provider_id] || call[:id]
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def normalize_input_tool_calls(tool_calls)
|
|
207
|
+
Array(tool_calls).to_h do |call|
|
|
208
|
+
provider_id = call[:provider_id] || call[:id]
|
|
209
|
+
arguments = JSON.parse(call.fetch(:arguments_json, "{}"))
|
|
210
|
+
[provider_id, {id: provider_id, name: call[:name], arguments: arguments}]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def text_content(content)
|
|
215
|
+
return content if content.is_a?(String) || content.nil?
|
|
216
|
+
|
|
217
|
+
Array(content).map do |part|
|
|
218
|
+
unless part[:type]&.to_sym == :text
|
|
219
|
+
raise UnsupportedOptionError, "unsupported content type: #{part[:type]}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
part[:text].to_s
|
|
223
|
+
end.join
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def normalized_tool(tool)
|
|
227
|
+
definition = tool.transform_keys(&:to_sym)
|
|
228
|
+
name = definition.fetch(:name).to_s
|
|
229
|
+
Class.new(RubyLLM::Tool).tap do |klass|
|
|
230
|
+
klass.define_singleton_method(:tool_name) { name }
|
|
231
|
+
klass.description(definition[:description].to_s)
|
|
232
|
+
klass.parameters(definition[:input_schema] || definition[:parameters] || {type: "object", properties: {}})
|
|
233
|
+
klass.define_method(:execute) { |**| raise "AgentHarness transports never execute tools" }
|
|
234
|
+
end.new
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def stream_events(chunk, state)
|
|
238
|
+
[text_event(chunk), *tool_call_events(chunk, state), usage_event(chunk, state)].compact
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def text_event(chunk)
|
|
242
|
+
{type: :text_delta, content: chunk.content} unless chunk.content.nil? || chunk.content.empty?
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def tool_call_events(chunk, state)
|
|
246
|
+
return [] unless chunk.tool_calls
|
|
247
|
+
|
|
248
|
+
chunk.tool_calls.flat_map { |stream_key, call| call_events(stream_key, call, state) }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Continuation chunks have a nil id and carry the provider's raw JSON
|
|
252
|
+
# fragment as arguments; they join the call that started their key.
|
|
253
|
+
def call_events(stream_key, call, state)
|
|
254
|
+
provider_id = call.id || state.provider_id(stream_key)
|
|
255
|
+
return [] unless provider_id
|
|
256
|
+
|
|
257
|
+
events = []
|
|
258
|
+
if state.start(stream_key, provider_id)
|
|
259
|
+
events << {type: :tool_call_started, provider_id: provider_id, name: call.name}
|
|
260
|
+
end
|
|
261
|
+
fragment = argument_fragment(call.arguments)
|
|
262
|
+
events << {type: :tool_call_delta, provider_id: provider_id, arguments_json: fragment} if fragment
|
|
263
|
+
events
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Deltas stay appendable JSON text; a start chunk may instead carry a
|
|
267
|
+
# complete parsed Hash, which becomes JSON once.
|
|
268
|
+
def argument_fragment(arguments)
|
|
269
|
+
case arguments
|
|
270
|
+
when String then arguments.empty? ? nil : arguments
|
|
271
|
+
when Hash then arguments.empty? ? nil : JSON.generate(arguments)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def usage_event(chunk, state)
|
|
276
|
+
return unless cumulative_usage_changed?(chunk, state)
|
|
277
|
+
|
|
278
|
+
{type: :usage_updated, **state.usage}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def cumulative_usage_changed?(chunk, state)
|
|
282
|
+
tokens = chunk.tokens
|
|
283
|
+
return false unless tokens
|
|
284
|
+
|
|
285
|
+
changed = false
|
|
286
|
+
if tokens.input && tokens.input != state.input_tokens
|
|
287
|
+
state.input_tokens = tokens.input
|
|
288
|
+
changed = true
|
|
289
|
+
end
|
|
290
|
+
if tokens.output && tokens.output != state.output_tokens
|
|
291
|
+
state.output_tokens = tokens.output
|
|
292
|
+
changed = true
|
|
293
|
+
end
|
|
294
|
+
changed
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def emit_completed_tool_calls(response)
|
|
298
|
+
Array(response.tool_calls&.values).each do |call|
|
|
299
|
+
yield(type: :tool_call_completed, **normalize_tool_call(call))
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def normalize_response(response)
|
|
304
|
+
{
|
|
305
|
+
content: response.content || "",
|
|
306
|
+
model: response.model,
|
|
307
|
+
finish_reason: response.finish_reason,
|
|
308
|
+
usage: normalize_usage(response.tokens),
|
|
309
|
+
tool_calls: Array(response.tool_calls&.values).map { |call| normalize_tool_call(call) }
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def normalize_tool_call(call)
|
|
314
|
+
{provider_id: call.id, name: call.name, arguments_json: JSON.generate(call.arguments || {})}
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def normalize_usage(tokens)
|
|
318
|
+
return unless tokens&.to_h&.any?
|
|
319
|
+
|
|
320
|
+
tokens.to_h.merge(total_tokens: (tokens.input && tokens.output) ? tokens.input + tokens.output : nil)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
data/lib/agent_harness.rb
CHANGED
|
@@ -471,6 +471,8 @@ 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"
|
|
475
|
+
require_relative "agent_harness/api/chat_transport"
|
|
474
476
|
require_relative "agent_harness/quota_status"
|
|
475
477
|
require_relative "agent_harness/authentication"
|
|
476
478
|
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.42.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bart Agapinan
|
|
@@ -135,6 +135,9 @@ 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
|
|
139
|
+
- lib/agent_harness/api/chat_transport.rb
|
|
140
|
+
- lib/agent_harness/api/ruby_llm_chat_adapter.rb
|
|
138
141
|
- lib/agent_harness/authentication.rb
|
|
139
142
|
- lib/agent_harness/command_executor.rb
|
|
140
143
|
- lib/agent_harness/configuration.rb
|