hermes-client 0.0.0 → 0.1.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/.yardopts +11 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.md +21 -0
- data/README.md +105 -7
- data/lib/hermes-client.rb +3 -8
- data/lib/hermes_agent/client/configuration.rb +98 -0
- data/lib/hermes_agent/client/conversation.rb +134 -0
- data/lib/hermes_agent/client/entities/capabilities.rb +289 -0
- data/lib/hermes_agent/client/entities/chat_completion.rb +370 -0
- data/lib/hermes_agent/client/entities/health.rb +140 -0
- data/lib/hermes_agent/client/entities/job.rb +394 -0
- data/lib/hermes_agent/client/entities/model.rb +68 -0
- data/lib/hermes_agent/client/entities/response.rb +429 -0
- data/lib/hermes_agent/client/entities/run.rb +427 -0
- data/lib/hermes_agent/client/entities/session_headers.rb +78 -0
- data/lib/hermes_agent/client/entity.rb +89 -0
- data/lib/hermes_agent/client/errors.rb +228 -0
- data/lib/hermes_agent/client/resources/capabilities.rb +34 -0
- data/lib/hermes_agent/client/resources/chat.rb +139 -0
- data/lib/hermes_agent/client/resources/health.rb +49 -0
- data/lib/hermes_agent/client/resources/jobs.rb +213 -0
- data/lib/hermes_agent/client/resources/models.rb +38 -0
- data/lib/hermes_agent/client/resources/responses.rb +204 -0
- data/lib/hermes_agent/client/resources/runs.rb +156 -0
- data/lib/hermes_agent/client/stream.rb +166 -0
- data/lib/hermes_agent/client/transport.rb +281 -0
- data/lib/hermes_agent/client/util.rb +56 -0
- data/lib/hermes_agent/client/version.rb +11 -0
- data/lib/hermes_agent/client.rb +137 -0
- metadata +72 -11
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hermes_agent/client/entities/model"
|
|
4
|
+
|
|
5
|
+
module HermesAgent
|
|
6
|
+
class Client
|
|
7
|
+
module Resources
|
|
8
|
+
##
|
|
9
|
+
# The models resource: discovery of the models the server advertises
|
|
10
|
+
# (`/v1/models`).
|
|
11
|
+
#
|
|
12
|
+
class Models
|
|
13
|
+
##
|
|
14
|
+
# Create the resource.
|
|
15
|
+
#
|
|
16
|
+
# @param transport [Transport] The transport used to issue requests.
|
|
17
|
+
#
|
|
18
|
+
# @private
|
|
19
|
+
def initialize(transport)
|
|
20
|
+
@transport = transport
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# List the models the server advertises.
|
|
25
|
+
#
|
|
26
|
+
# @return [Array<Entities::Model>] The advertised models. Empty when
|
|
27
|
+
# the server returns no `data` array.
|
|
28
|
+
#
|
|
29
|
+
def list
|
|
30
|
+
data = @transport.get("/v1/models")["data"]
|
|
31
|
+
return [] unless data.is_a?(::Array)
|
|
32
|
+
|
|
33
|
+
data.map { |item| Entities::Model.new(item) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hermes_agent/client/entities/response"
|
|
4
|
+
require "hermes_agent/client/conversation"
|
|
5
|
+
require "hermes_agent/client/util"
|
|
6
|
+
|
|
7
|
+
module HermesAgent
|
|
8
|
+
class Client
|
|
9
|
+
module Resources
|
|
10
|
+
##
|
|
11
|
+
# The responses resource: the Responses API (`/v1/responses`). Unlike
|
|
12
|
+
# chat completions, the server persists conversation state, so turns can
|
|
13
|
+
# be chained (via `previous_response_id` or a named `conversation`) and a
|
|
14
|
+
# response can be retrieved or deleted by id afterward. On a server
|
|
15
|
+
# configured with an API key, these calls require a bearer token (see
|
|
16
|
+
# {Client} / {Configuration}).
|
|
17
|
+
#
|
|
18
|
+
# Server-side storage is capped (LRU eviction), so callers should not
|
|
19
|
+
# assume an older response remains retrievable.
|
|
20
|
+
#
|
|
21
|
+
class Responses
|
|
22
|
+
##
|
|
23
|
+
# Create the resource.
|
|
24
|
+
#
|
|
25
|
+
# @param transport [Transport] The transport used to issue requests.
|
|
26
|
+
#
|
|
27
|
+
# @private
|
|
28
|
+
def initialize(transport)
|
|
29
|
+
@transport = transport
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# Create a response.
|
|
34
|
+
#
|
|
35
|
+
# No `model` is sent: the model is configured server-side and the
|
|
36
|
+
# server ignores a client-supplied one. (A caller who really wants to
|
|
37
|
+
# send fields we have not modeled — including `model` — can pass them
|
|
38
|
+
# through `extra`.)
|
|
39
|
+
#
|
|
40
|
+
# @param input [String, Array<Hash>] The input: a plain string, or an
|
|
41
|
+
# array of input items (which may include `input_image` parts for
|
|
42
|
+
# inline images).
|
|
43
|
+
# @param previous_response_id [String, nil] The id of a prior response
|
|
44
|
+
# to chain this turn onto. Omitted from the request when `nil`.
|
|
45
|
+
# @param conversation [String, nil] A stable conversation name to chain
|
|
46
|
+
# this turn onto. Omitted from the request when `nil`.
|
|
47
|
+
# @param idempotency_key [String, nil] An idempotency key, sent as the
|
|
48
|
+
# `Idempotency-Key` request header. The server caches the result for
|
|
49
|
+
# ~5 minutes and replays it for a repeat call carrying the same key
|
|
50
|
+
# and an equivalent request, so a retry does not re-run the model.
|
|
51
|
+
# The replay is **transparent**: the response is indistinguishable
|
|
52
|
+
# from a fresh one (same status, no replay header, and a freshly
|
|
53
|
+
# regenerated `id`), so there is no way — here or in the returned
|
|
54
|
+
# entity — to tell whether a given call was served from the cache.
|
|
55
|
+
# Reusing a key with a *different* request silently recomputes (no
|
|
56
|
+
# error) and overwrites the cached entry. Honored only on this
|
|
57
|
+
# non-streaming endpoint (not on {#stream_create}).
|
|
58
|
+
# @param extra [Hash] Additional request-body fields merged into the
|
|
59
|
+
# body as-is.
|
|
60
|
+
# @return [Entities::Response] The response. Its
|
|
61
|
+
# {Entities::SessionHeaders#session_id} carries the server-generated
|
|
62
|
+
# session id from the response headers (this endpoint does not
|
|
63
|
+
# accept a session on the request).
|
|
64
|
+
# @raise [APIError] If the server returns a non-2xx response.
|
|
65
|
+
#
|
|
66
|
+
def create(input:, previous_response_id: nil, conversation: nil, idempotency_key: nil, **extra)
|
|
67
|
+
body = build_body(input, previous_response_id, conversation, extra)
|
|
68
|
+
headers = idempotency_key ? {"Idempotency-Key" => idempotency_key} : nil
|
|
69
|
+
result = @transport.post("/v1/responses", body, headers: headers)
|
|
70
|
+
Entities::Response.new(result.body, **Util.session_headers(result.headers))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
##
|
|
74
|
+
# Create a response, streaming the turn's events.
|
|
75
|
+
#
|
|
76
|
+
# With a block, each {Entities::ResponseStreamEvent} is yielded as it
|
|
77
|
+
# arrives and the assembled {Entities::Response} is returned once the
|
|
78
|
+
# stream closes. Without a block, a {Stream} is returned for the caller
|
|
79
|
+
# to iterate; its {Stream#result} is the assembled response. The final
|
|
80
|
+
# response is taken from the terminal `response.completed` event (see
|
|
81
|
+
# {Entities::Response.from_events}).
|
|
82
|
+
#
|
|
83
|
+
# @param input [String, Array<Hash>] The input (see {#create}).
|
|
84
|
+
# @param previous_response_id [String, nil] The id of a prior response
|
|
85
|
+
# to chain onto. Omitted from the request when `nil`.
|
|
86
|
+
# @param conversation [String, nil] A stable conversation name to chain
|
|
87
|
+
# onto. Omitted from the request when `nil`.
|
|
88
|
+
# @param extra [Hash] Additional request-body fields merged into the
|
|
89
|
+
# body as-is.
|
|
90
|
+
# @yieldparam event [Entities::ResponseStreamEvent] Each streamed event.
|
|
91
|
+
# @return [Entities::Response, Stream] The assembled response when a
|
|
92
|
+
# block is given, otherwise the {Stream}.
|
|
93
|
+
# @raise [APIError] If the server returns a non-2xx response.
|
|
94
|
+
#
|
|
95
|
+
def stream_create(input:, previous_response_id: nil, conversation: nil, **extra, &)
|
|
96
|
+
stream_response(on_result: nil, input: input, previous_response_id: previous_response_id,
|
|
97
|
+
conversation: conversation, **extra, &)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
##
|
|
101
|
+
# Create a streamed response, with an optional hook invoked with the
|
|
102
|
+
# assembled {Entities::Response} once the stream is consumed. This is
|
|
103
|
+
# the shared implementation behind {#stream_create}; the `on_result`
|
|
104
|
+
# hook is folded into the stream's aggregator (so it fires exactly when
|
|
105
|
+
# the final response is built, for both the block and enumerator forms)
|
|
106
|
+
# rather than exposed as a settable hook on the returned {Stream}, where
|
|
107
|
+
# a caller could clobber it. It exists for {Conversation} to capture the
|
|
108
|
+
# new response id for chaining; it is not part of the public API.
|
|
109
|
+
#
|
|
110
|
+
# @private
|
|
111
|
+
# @param on_result [#call, nil] Called with the assembled
|
|
112
|
+
# {Entities::Response} when the stream's result is built. May be nil.
|
|
113
|
+
# @param input [String, Array<Hash>] The input (see {#create}).
|
|
114
|
+
# @param previous_response_id [String, nil] The prior response id to
|
|
115
|
+
# chain onto. Omitted from the request when nil.
|
|
116
|
+
# @param conversation [String, nil] A conversation name to chain onto.
|
|
117
|
+
# Omitted from the request when nil.
|
|
118
|
+
# @param extra [Hash] Additional request-body fields.
|
|
119
|
+
# @yieldparam event [Entities::ResponseStreamEvent] Each streamed event.
|
|
120
|
+
# @return [Entities::Response, Stream] The assembled response when a
|
|
121
|
+
# block is given, otherwise the {Stream}.
|
|
122
|
+
#
|
|
123
|
+
def stream_response(on_result:, input:, previous_response_id: nil, conversation: nil, **extra, &block)
|
|
124
|
+
body = build_body(input, previous_response_id, conversation, extra)
|
|
125
|
+
body[:stream] = true
|
|
126
|
+
result = @transport.stream_post("/v1/responses", body)
|
|
127
|
+
session = Util.session_headers(result.headers)
|
|
128
|
+
stream = Stream.new(result.body, event_class: Entities::ResponseStreamEvent) do |events|
|
|
129
|
+
response = Entities::Response.from_events(events, **session)
|
|
130
|
+
on_result&.call(response)
|
|
131
|
+
response
|
|
132
|
+
end
|
|
133
|
+
return stream unless block
|
|
134
|
+
|
|
135
|
+
stream.each(&block)
|
|
136
|
+
stream.result
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
##
|
|
140
|
+
# Begin a multi-turn conversation that automatically chains its turns.
|
|
141
|
+
#
|
|
142
|
+
# Returns a {Conversation} — a stateful helper wrapping this resource —
|
|
143
|
+
# whose `create` / `stream_create` take only the per-turn `input:` (plus
|
|
144
|
+
# `extra`) and handle chaining for you. With no arguments it tracks the
|
|
145
|
+
# `previous_response_id` of each turn client-side and threads it into the
|
|
146
|
+
# next; pass `name:` instead to chain via a server-side named
|
|
147
|
+
# conversation; pass `previous_response_id:` to resume a client-side
|
|
148
|
+
# thread from a known id. `name:` and `previous_response_id:` are
|
|
149
|
+
# mutually exclusive (they select different chaining mechanisms).
|
|
150
|
+
#
|
|
151
|
+
# @param name [String, nil] A stable conversation name for server-side
|
|
152
|
+
# chaining. Mutually exclusive with `previous_response_id`.
|
|
153
|
+
# @param previous_response_id [String, nil] A prior response id to seed
|
|
154
|
+
# client-side chaining from. Mutually exclusive with `name`.
|
|
155
|
+
# @return [Conversation]
|
|
156
|
+
#
|
|
157
|
+
def conversation(name: nil, previous_response_id: nil)
|
|
158
|
+
Conversation.new(self, name: name, previous_response_id: previous_response_id)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
##
|
|
162
|
+
# Retrieve a previously created response by id.
|
|
163
|
+
#
|
|
164
|
+
# @param id [String] The response id (`"resp_…"`).
|
|
165
|
+
# @return [Entities::Response] The response.
|
|
166
|
+
# @raise [NotFoundError] If no such response exists (or it was evicted).
|
|
167
|
+
# @raise [APIError] If the server returns another non-2xx response.
|
|
168
|
+
#
|
|
169
|
+
def get(id)
|
|
170
|
+
Entities::Response.new(@transport.get("/v1/responses/#{id}"))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
##
|
|
174
|
+
# Delete a response by id.
|
|
175
|
+
#
|
|
176
|
+
# @param id [String] The response id (`"resp_…"`).
|
|
177
|
+
# @return [Entities::ResponseDeletion] The deletion result.
|
|
178
|
+
# @raise [APIError] If the server returns a non-2xx response.
|
|
179
|
+
#
|
|
180
|
+
def delete(id)
|
|
181
|
+
Entities::ResponseDeletion.new(@transport.delete("/v1/responses/#{id}"))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
##
|
|
187
|
+
# Build the request body, including chaining fields only when present.
|
|
188
|
+
#
|
|
189
|
+
# @param input [String, Array<Hash>] The input.
|
|
190
|
+
# @param previous_response_id [String, nil] The prior response id.
|
|
191
|
+
# @param conversation [String, nil] The conversation name.
|
|
192
|
+
# @param extra [Hash] Additional body fields.
|
|
193
|
+
# @return [Hash] The request body.
|
|
194
|
+
#
|
|
195
|
+
def build_body(input, previous_response_id, conversation, extra)
|
|
196
|
+
body = {input: input, **extra}
|
|
197
|
+
body[:previous_response_id] = previous_response_id if previous_response_id
|
|
198
|
+
body[:conversation] = conversation if conversation
|
|
199
|
+
body
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hermes_agent/client/entities/run"
|
|
4
|
+
|
|
5
|
+
module HermesAgent
|
|
6
|
+
class Client
|
|
7
|
+
module Resources
|
|
8
|
+
##
|
|
9
|
+
# The runs resource: the Runs API (`/v1/runs`) for long-running agent
|
|
10
|
+
# runs. Unlike chat completions and the Responses API, a run is
|
|
11
|
+
# **server-side asynchronous**: {#create} returns immediately (HTTP `202`)
|
|
12
|
+
# with a minimal {Entities::Run} carrying only its `run_id` and `status`,
|
|
13
|
+
# and progress is tracked by polling {#get} or by subscribing to its
|
|
14
|
+
# event stream with {#stream_events}. On a server configured with an API
|
|
15
|
+
# key, these calls require a bearer token (see {Client} / {Configuration}).
|
|
16
|
+
#
|
|
17
|
+
# Run records are retained only briefly after they reach a terminal
|
|
18
|
+
# status, then evicted, so callers should not assume an older run remains
|
|
19
|
+
# retrievable.
|
|
20
|
+
#
|
|
21
|
+
class Runs
|
|
22
|
+
##
|
|
23
|
+
# Create the resource.
|
|
24
|
+
#
|
|
25
|
+
# @param transport [Transport] The transport used to issue requests.
|
|
26
|
+
#
|
|
27
|
+
# @private
|
|
28
|
+
def initialize(transport)
|
|
29
|
+
@transport = transport
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# Start a run.
|
|
34
|
+
#
|
|
35
|
+
# Returns as soon as the run is accepted; the returned {Entities::Run}
|
|
36
|
+
# carries only its `run_id` and an initial `status` of `"started"`. Poll
|
|
37
|
+
# {#get} with that id to follow the run to a terminal status.
|
|
38
|
+
#
|
|
39
|
+
# No `model` is sent: the model is configured server-side. (A caller who
|
|
40
|
+
# really wants to send fields we have not modeled — including `model` —
|
|
41
|
+
# can pass them through `extra`.)
|
|
42
|
+
#
|
|
43
|
+
# @param input [String] The user prompt (required).
|
|
44
|
+
# @param instructions [String, nil] A system directive layered over the
|
|
45
|
+
# agent prompt. Omitted from the request when `nil`.
|
|
46
|
+
# @param conversation_history [Array<Hash>, nil] Prior turns as an
|
|
47
|
+
# OpenAI-style message array (`[{role:, content:}, …]`), loaded into
|
|
48
|
+
# the run's context. Omitted from the request when `nil`.
|
|
49
|
+
# @param previous_response_id [String, nil] The id of a stored
|
|
50
|
+
# `/v1/responses` response whose context should be loaded into the
|
|
51
|
+
# run. Omitted from the request when `nil`.
|
|
52
|
+
# @param session_id [String, nil] A correlation label, stored and echoed
|
|
53
|
+
# back on the poll. Omitted from the request when `nil`.
|
|
54
|
+
# @param extra [Hash] Additional request-body fields merged in as-is.
|
|
55
|
+
# @return [Entities::Run] The accepted run (minimal: `run_id` + status).
|
|
56
|
+
# @raise [APIError] If the server returns a non-2xx response.
|
|
57
|
+
#
|
|
58
|
+
def create(input:, instructions: nil, conversation_history: nil,
|
|
59
|
+
previous_response_id: nil, session_id: nil, **extra)
|
|
60
|
+
body = {input: input, **extra}
|
|
61
|
+
body[:instructions] = instructions if instructions
|
|
62
|
+
body[:conversation_history] = conversation_history if conversation_history
|
|
63
|
+
body[:previous_response_id] = previous_response_id if previous_response_id
|
|
64
|
+
body[:session_id] = session_id if session_id
|
|
65
|
+
Entities::Run.new(@transport.post("/v1/runs", body).body)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
##
|
|
69
|
+
# Retrieve a run by id, to poll its progress and status.
|
|
70
|
+
#
|
|
71
|
+
# @param run_id [String] The run id (`"run_…"`).
|
|
72
|
+
# @return [Entities::Run] The current run state.
|
|
73
|
+
# @raise [NotFoundError] If no such run exists (or it was evicted).
|
|
74
|
+
# @raise [APIError] If the server returns another non-2xx response.
|
|
75
|
+
#
|
|
76
|
+
def get(run_id)
|
|
77
|
+
Entities::Run.new(@transport.get("/v1/runs/#{run_id}"))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
##
|
|
81
|
+
# Request that a run stop.
|
|
82
|
+
#
|
|
83
|
+
# The stop is cooperative: this returns as soon as the request is
|
|
84
|
+
# accepted, with an ack carrying `status: "stopping"`. The run then
|
|
85
|
+
# resolves to a terminal `"cancelled"` status — poll {#get} to observe
|
|
86
|
+
# that transition.
|
|
87
|
+
#
|
|
88
|
+
# @param run_id [String] The run id (`"run_…"`).
|
|
89
|
+
# @return [Entities::RunStop] The stop acknowledgement.
|
|
90
|
+
# @raise [NotFoundError] If no such run exists (or it was evicted).
|
|
91
|
+
# @raise [APIError] If the server returns another non-2xx response.
|
|
92
|
+
#
|
|
93
|
+
def stop(run_id)
|
|
94
|
+
Entities::RunStop.new(@transport.post("/v1/runs/#{run_id}/stop", {}).body)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
##
|
|
98
|
+
# Stream a run's events as they occur (its tool-call progress, token
|
|
99
|
+
# deltas, reasoning, approval prompts, and lifecycle), following the
|
|
100
|
+
# block-or-enumerator pattern.
|
|
101
|
+
#
|
|
102
|
+
# With a block, each {Entities::RunEvent} is yielded as it arrives and
|
|
103
|
+
# the terminal `run.*` event is returned once the stream closes. Without
|
|
104
|
+
# a block, a {Stream} is returned for the caller to iterate; its
|
|
105
|
+
# {Stream#result} is that terminal event (see
|
|
106
|
+
# {Entities::RunEvent.terminal}). Subscribing replays a run from its
|
|
107
|
+
# first event, so an already-terminal run can still be streamed during
|
|
108
|
+
# its (brief) retention window.
|
|
109
|
+
#
|
|
110
|
+
# @param run_id [String] The run id (`"run_…"`).
|
|
111
|
+
# @yieldparam event [Entities::RunEvent] Each streamed event.
|
|
112
|
+
# @return [Entities::RunEvent, Stream, nil] The terminal event when a
|
|
113
|
+
# block is given (or `nil` if the stream closed without one),
|
|
114
|
+
# otherwise the {Stream}.
|
|
115
|
+
# @raise [NotFoundError] If no such run exists (or it was evicted).
|
|
116
|
+
# @raise [APIError] If the server returns another non-2xx response.
|
|
117
|
+
#
|
|
118
|
+
def stream_events(run_id, &block)
|
|
119
|
+
chunks = @transport.stream_get("/v1/runs/#{run_id}/events")
|
|
120
|
+
stream = Stream.new(chunks, event_class: Entities::RunEvent) do |events|
|
|
121
|
+
Entities::RunEvent.terminal(events)
|
|
122
|
+
end
|
|
123
|
+
return stream unless block
|
|
124
|
+
|
|
125
|
+
stream.each(&block)
|
|
126
|
+
stream.result
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
##
|
|
130
|
+
# Answer a run's pending approval, when a gated dangerous command has
|
|
131
|
+
# parked it at `waiting_for_approval`.
|
|
132
|
+
#
|
|
133
|
+
# A run has at most one outstanding approval, keyed by its id, so only
|
|
134
|
+
# the `run_id` and a `choice` are needed. `"once"` approves this one
|
|
135
|
+
# invocation; `"deny"` rejects it (the run still ends `completed`). Note
|
|
136
|
+
# `"always"` writes a permanent server-side allowlist entry and
|
|
137
|
+
# `"session"` auto-approves the pattern for the rest of the gateway
|
|
138
|
+
# session — prefer `"once"`/`"deny"` unless those side effects are
|
|
139
|
+
# intended. An invalid choice is rejected by the server.
|
|
140
|
+
#
|
|
141
|
+
# @param run_id [String] The run id (`"run_…"`).
|
|
142
|
+
# @param choice [String] One of `"once"`, `"session"`, `"always"`, or
|
|
143
|
+
# `"deny"`.
|
|
144
|
+
# @return [Entities::RunApprovalResponse] The approval acknowledgement.
|
|
145
|
+
# @raise [NotFoundError] If no such run exists (or it was evicted).
|
|
146
|
+
# @raise [APIError] On an invalid choice (`400`) or another non-2xx
|
|
147
|
+
# response.
|
|
148
|
+
#
|
|
149
|
+
def respond_approval(run_id, choice:)
|
|
150
|
+
body = {choice: choice}
|
|
151
|
+
Entities::RunApprovalResponse.new(@transport.post("/v1/runs/#{run_id}/approval", body).body)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require "hermes_agent/client/errors"
|
|
6
|
+
require "hermes_agent/client/util"
|
|
7
|
+
|
|
8
|
+
module HermesAgent
|
|
9
|
+
class Client
|
|
10
|
+
##
|
|
11
|
+
# A consumable Server-Sent Events stream.
|
|
12
|
+
#
|
|
13
|
+
# `Stream` parses the SSE frames emitted by a streaming endpoint, wrapping
|
|
14
|
+
# each frame's `data` payload (parsed as JSON) in an event wrapper object,
|
|
15
|
+
# and implements the block-or-enumerator contract:
|
|
16
|
+
#
|
|
17
|
+
# - Iterated with a block (via {#each}), it yields each event as it
|
|
18
|
+
# arrives, giving natural backpressure over the network read.
|
|
19
|
+
# - Without a block it is an `Enumerable` the caller drives itself.
|
|
20
|
+
#
|
|
21
|
+
# After the stream is fully consumed, {#result} returns the aggregated
|
|
22
|
+
# final object (built by the aggregator block given at construction). It is
|
|
23
|
+
# a **single-pass** stream over a live network read: it can be iterated
|
|
24
|
+
# once.
|
|
25
|
+
#
|
|
26
|
+
# The class is HTTP-agnostic — it consumes anything that yields String
|
|
27
|
+
# byte chunks via `#each` (the `http` gem's response body, or an array of
|
|
28
|
+
# chunks in tests) — so it never owns or manages the network connection
|
|
29
|
+
# itself.
|
|
30
|
+
#
|
|
31
|
+
class Stream
|
|
32
|
+
include ::Enumerable
|
|
33
|
+
|
|
34
|
+
##
|
|
35
|
+
# Create a stream.
|
|
36
|
+
#
|
|
37
|
+
# @param chunks [#each] A source of String byte chunks (e.g. an `http`
|
|
38
|
+
# response body).
|
|
39
|
+
# @param event_class [Class, #call] How each frame's parsed data is
|
|
40
|
+
# wrapped. A {Entity} subclass wraps every frame regardless of its
|
|
41
|
+
# SSE `event:` name. A callable instead receives the frame's event
|
|
42
|
+
# name (`nil` for an unnamed frame) and returns the {Entity} subclass
|
|
43
|
+
# to use, so a single stream can surface heterogeneous event types
|
|
44
|
+
# (e.g. chat's `hermes.tool.progress` frames vs. completion chunks).
|
|
45
|
+
# @param terminator [String, nil] A sentinel `data` payload that marks
|
|
46
|
+
# the end of the stream (e.g. `"[DONE]"` for chat completions). The
|
|
47
|
+
# terminator frame is not yielded. `nil` means the stream simply
|
|
48
|
+
# ends when the connection closes.
|
|
49
|
+
# @yieldparam events [Array<Entity>] All events seen, in order; the
|
|
50
|
+
# block returns the aggregated {#result}. Optional — without it
|
|
51
|
+
# {#result} is `nil`.
|
|
52
|
+
#
|
|
53
|
+
# @private
|
|
54
|
+
def initialize(chunks, event_class:, terminator: nil, &aggregator)
|
|
55
|
+
@chunks = chunks
|
|
56
|
+
@event_class = event_class
|
|
57
|
+
@terminator = terminator
|
|
58
|
+
@aggregator = aggregator
|
|
59
|
+
@buffer = +""
|
|
60
|
+
@data_lines = []
|
|
61
|
+
@event_name = nil
|
|
62
|
+
@events = []
|
|
63
|
+
@consumed = false
|
|
64
|
+
@result = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# Iterate the events. With a block, yields each event as it is parsed and
|
|
69
|
+
# returns `self`. Without a block, returns an `Enumerator`.
|
|
70
|
+
#
|
|
71
|
+
# @yieldparam event [Entity] Each parsed event, in order.
|
|
72
|
+
# @return [self, Enumerator]
|
|
73
|
+
# @raise [Error] If the stream has already been consumed.
|
|
74
|
+
#
|
|
75
|
+
def each(&block)
|
|
76
|
+
return enum_for(:each) unless block
|
|
77
|
+
|
|
78
|
+
raise Error, "Stream has already been consumed" if @consumed
|
|
79
|
+
|
|
80
|
+
consume(&block)
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# The aggregated final object, available after the stream is consumed.
|
|
86
|
+
# Consuming the stream first if it has not been iterated.
|
|
87
|
+
#
|
|
88
|
+
# @return [Object, nil] Whatever the aggregator block returned, or `nil`
|
|
89
|
+
# when no aggregator was given.
|
|
90
|
+
#
|
|
91
|
+
def result
|
|
92
|
+
consume unless @consumed
|
|
93
|
+
@result
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
##
|
|
99
|
+
# Read every chunk, parse frames, accumulate events, and build the
|
|
100
|
+
# aggregated result. Yields each event if a block is given.
|
|
101
|
+
#
|
|
102
|
+
def consume
|
|
103
|
+
@consumed = true
|
|
104
|
+
@chunks.each do |chunk|
|
|
105
|
+
@buffer << chunk
|
|
106
|
+
while (newline = @buffer.index("\n"))
|
|
107
|
+
event = process_line(@buffer.slice!(0, newline + 1).chomp)
|
|
108
|
+
next unless event
|
|
109
|
+
|
|
110
|
+
@events << event
|
|
111
|
+
yield event if block_given?
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
@result = @aggregator&.call(@events)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
##
|
|
118
|
+
# Process one SSE line. Accumulates `data` fields, records the frame's
|
|
119
|
+
# `event:` name, and on a blank line dispatches the buffered frame.
|
|
120
|
+
#
|
|
121
|
+
# @param line [String] One line, without its trailing newline.
|
|
122
|
+
# @return [Entity, nil] The event when a frame completes, else `nil`.
|
|
123
|
+
#
|
|
124
|
+
def process_line(line)
|
|
125
|
+
return dispatch if line.empty?
|
|
126
|
+
return nil if line.start_with?(":")
|
|
127
|
+
|
|
128
|
+
field, separator, value = line.partition(":")
|
|
129
|
+
value = value.sub(/\A /, "") unless separator.empty?
|
|
130
|
+
case field
|
|
131
|
+
when "data" then @data_lines << value
|
|
132
|
+
when "event" then @event_name = value
|
|
133
|
+
end
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
##
|
|
138
|
+
# Emit the buffered frame, unless it is empty or the terminator. Resets
|
|
139
|
+
# the per-frame state (data lines and event name) either way so it does
|
|
140
|
+
# not leak into the next frame.
|
|
141
|
+
#
|
|
142
|
+
# @return [Entity, nil]
|
|
143
|
+
#
|
|
144
|
+
def dispatch
|
|
145
|
+
data = @data_lines.empty? ? nil : @data_lines.join("\n")
|
|
146
|
+
name = @event_name
|
|
147
|
+
@data_lines = []
|
|
148
|
+
@event_name = nil
|
|
149
|
+
return nil if data.nil? || (@terminator && data == @terminator)
|
|
150
|
+
|
|
151
|
+
event_class_for(name).new(Util.parse_json(data))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
##
|
|
155
|
+
# Resolve the {Entity} subclass for a frame: a callable `event_class`
|
|
156
|
+
# chooses by event name, otherwise the class itself is used for all.
|
|
157
|
+
#
|
|
158
|
+
# @param name [String, nil] The frame's SSE `event:` name.
|
|
159
|
+
# @return [Class]
|
|
160
|
+
#
|
|
161
|
+
def event_class_for(name)
|
|
162
|
+
@event_class.respond_to?(:call) ? @event_class.call(name) : @event_class
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|