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.
@@ -0,0 +1,427 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hermes_agent/client/entity"
4
+
5
+ module HermesAgent
6
+ class Client
7
+ module Entities
8
+ ##
9
+ # The token usage reported for a {Run} ({Run#usage}). Like the Responses
10
+ # API (and unlike chat completions), runs report
11
+ # `input_tokens`/`output_tokens` rather than
12
+ # `prompt_tokens`/`completion_tokens`.
13
+ #
14
+ class RunUsage < Entity
15
+ ##
16
+ # The number of tokens in the input.
17
+ # @return [Integer, nil]
18
+ #
19
+ def input_tokens
20
+ self["input_tokens"]
21
+ end
22
+
23
+ ##
24
+ # The number of tokens in the generated output.
25
+ # @return [Integer, nil]
26
+ #
27
+ def output_tokens
28
+ self["output_tokens"]
29
+ end
30
+
31
+ ##
32
+ # The total number of tokens used (input plus output).
33
+ # @return [Integer, nil]
34
+ #
35
+ def total_tokens
36
+ self["total_tokens"]
37
+ end
38
+ end
39
+
40
+ ##
41
+ # A run from the Runs API (`POST /v1/runs`, `GET /v1/runs/{id}`). Unlike
42
+ # chat/responses a run is server-side asynchronous: `create` returns
43
+ # immediately with a minimal run (only {#run_id} and {#status}), and
44
+ # progress is tracked by polling ({Resources::Runs#get}) or subscribing to
45
+ # the event stream. {#output} and {#usage} are populated only once the run
46
+ # reaches a terminal `completed` status, and are absent before then and on
47
+ # a `cancelled` or `failed` run — so both readers tolerate a missing
48
+ # value. A `failed` run instead carries an {#error} message. Field readers
49
+ # are best-effort; {#to_h} remains the source of truth.
50
+ #
51
+ class Run < Entity
52
+ ##
53
+ # The run id, e.g. `"run_…"` (`run_` plus 32 hex characters). Pass it to
54
+ # {Resources::Runs#get} to poll, or to the streaming/stop/approval calls.
55
+ # @return [String, nil]
56
+ #
57
+ def run_id
58
+ self["run_id"]
59
+ end
60
+ alias id run_id
61
+
62
+ ##
63
+ # The object type, `"hermes.run"`.
64
+ # @return [String, nil]
65
+ #
66
+ def object
67
+ self["object"]
68
+ end
69
+
70
+ ##
71
+ # The run status: `"started"` → `"running"` → terminal `"completed"` /
72
+ # `"cancelled"` / `"failed"`, with `"waiting_for_approval"` while a gated
73
+ # tool awaits a response and a transient `"stopping"` after a stop.
74
+ # @return [String, nil]
75
+ #
76
+ def status
77
+ self["status"]
78
+ end
79
+
80
+ ##
81
+ # When the run was created, as a Unix timestamp (seconds, fractional).
82
+ # @return [Float, nil]
83
+ #
84
+ def created_at
85
+ self["created_at"]
86
+ end
87
+
88
+ ##
89
+ # When the run was last updated, as a Unix timestamp (seconds,
90
+ # fractional).
91
+ # @return [Float, nil]
92
+ #
93
+ def updated_at
94
+ self["updated_at"]
95
+ end
96
+
97
+ ##
98
+ # The session correlation label for the run (defaults to the {#run_id}
99
+ # when none was supplied on create).
100
+ # @return [String, nil]
101
+ #
102
+ def session_id
103
+ self["session_id"]
104
+ end
105
+
106
+ ##
107
+ # The model that produced the run, e.g. `"hermes-test"` (configured
108
+ # server-side).
109
+ # @return [String, nil]
110
+ #
111
+ def model
112
+ self["model"]
113
+ end
114
+
115
+ ##
116
+ # The name of the most recent event emitted on the run's event stream,
117
+ # e.g. `"run.completed"`. Empty at the very start of a run.
118
+ # @return [String, nil]
119
+ #
120
+ def last_event
121
+ self["last_event"]
122
+ end
123
+
124
+ ##
125
+ # The assembled final assistant text, present once the run completes.
126
+ # Absent (nil) before the run is terminal and on a cancelled run.
127
+ # @return [String, nil]
128
+ #
129
+ def output
130
+ self["output"]
131
+ end
132
+
133
+ ##
134
+ # The failure message on a `failed` run (the upstream error, e.g. a
135
+ # model/provider error). Present only when {#status} is `"failed"`;
136
+ # `nil` otherwise. A failed run otherwise looks like a cancelled one —
137
+ # {#output} and {#usage} are absent.
138
+ # @return [String, nil]
139
+ #
140
+ def error
141
+ self["error"]
142
+ end
143
+
144
+ ##
145
+ # The token usage, wrapped in a {RunUsage}. Present once the run
146
+ # completes; returns `nil` when the field is absent (before terminal, or
147
+ # on a cancelled run).
148
+ # @return [RunUsage, nil]
149
+ #
150
+ def usage
151
+ raw = self["usage"]
152
+ raw.is_a?(::Hash) ? RunUsage.new(raw) : nil
153
+ end
154
+ end
155
+
156
+ ##
157
+ # The acknowledgement returned by stopping a run
158
+ # ({Resources::Runs#stop}): `{run_id, status: "stopping"}`. Stop is
159
+ # cooperative — this ack only confirms the stop was accepted; the run
160
+ # then resolves to a terminal `"cancelled"` status, observable by polling
161
+ # {Resources::Runs#get}. It is deliberately not a full {Run} (it carries
162
+ # no output, usage, or timestamps).
163
+ #
164
+ class RunStop < Entity
165
+ ##
166
+ # The id of the run being stopped (`"run_…"`).
167
+ # @return [String, nil]
168
+ #
169
+ def run_id
170
+ self["run_id"]
171
+ end
172
+
173
+ ##
174
+ # The status acknowledged by the stop, `"stopping"`.
175
+ # @return [String, nil]
176
+ #
177
+ def status
178
+ self["status"]
179
+ end
180
+ end
181
+
182
+ ##
183
+ # One event in a streamed run ({Resources::Runs#stream_events}).
184
+ #
185
+ # Unlike chat and the Responses API, the run events stream uses plain
186
+ # `data:` frames — there is no SSE `event:` line and no `[DONE]` sentinel
187
+ # — so each event carries its type in an `"event"` payload field (read via
188
+ # {#event}) alongside {#run_id} and a {#timestamp}. The stream has no
189
+ # head frame; it begins at the first content event. Which other readers
190
+ # are meaningful depends on {#event}; the rest return `nil`. Observed
191
+ # types: `tool.started`/`tool.completed`, `message.delta`,
192
+ # `reasoning.available`, the terminal `run.completed`/`run.cancelled`/
193
+ # `run.failed` (the last carries an {#error} string), and
194
+ # `approval.request`/`approval.responded`.
195
+ #
196
+ class RunEvent < Entity
197
+ ##
198
+ # The terminal lifecycle event of a streamed run: the last event whose
199
+ # {#event} type is a `run.*` frame (`run.completed`, `run.cancelled`, or
200
+ # `run.failed`). Returns `nil` if the stream closed without one (e.g. it
201
+ # was cut short). Used as the aggregated {Stream#result} of
202
+ # {Resources::Runs#stream_events}.
203
+ #
204
+ # @param events [Array<RunEvent>] The streamed events, in order.
205
+ # @return [RunEvent, nil]
206
+ #
207
+ def self.terminal(events)
208
+ events.reverse_each.find { |event| event.event&.start_with?("run.") }
209
+ end
210
+
211
+ ##
212
+ # The event type, e.g. `"message.delta"` or `"run.completed"`.
213
+ # @return [String, nil]
214
+ #
215
+ def event
216
+ self["event"]
217
+ end
218
+
219
+ ##
220
+ # The id of the run this event belongs to (`"run_…"`).
221
+ # @return [String, nil]
222
+ #
223
+ def run_id
224
+ self["run_id"]
225
+ end
226
+
227
+ ##
228
+ # When the event was emitted, as a Unix timestamp (seconds, fractional).
229
+ # @return [Float, nil]
230
+ #
231
+ def timestamp
232
+ self["timestamp"]
233
+ end
234
+
235
+ ##
236
+ # The tool name on a `tool.started` / `tool.completed` event, e.g.
237
+ # `"terminal"`.
238
+ # @return [String, nil]
239
+ #
240
+ def tool
241
+ self["tool"]
242
+ end
243
+
244
+ ##
245
+ # A preview of the tool invocation on a `tool.started` event (e.g. the
246
+ # command to be run).
247
+ # @return [String, nil]
248
+ #
249
+ def preview
250
+ self["preview"]
251
+ end
252
+
253
+ ##
254
+ # The tool's execution time on a `tool.completed` event, in seconds.
255
+ # @return [Float, nil]
256
+ #
257
+ def duration
258
+ self["duration"]
259
+ end
260
+
261
+ ##
262
+ # Whether the tool reported an error on a `tool.completed` event. This
263
+ # is the tool *result* signal, not a lifecycle marker: a failed command
264
+ # — or a denied approval — reports `true` yet the run can still complete.
265
+ # Returns `nil` when there is no boolean flag (the field is absent, or
266
+ # the event is a `run.failed` whose `error` is a message string — read
267
+ # that via {#error}).
268
+ # @return [boolean, nil]
269
+ #
270
+ def error?
271
+ value = self["error"]
272
+ value if [true, false].include?(value)
273
+ end
274
+
275
+ ##
276
+ # The failure message on a `run.failed` event (a string — the upstream
277
+ # error). `nil` on any other event, including a `tool.completed` whose
278
+ # `error` is the boolean result flag (read that via {#error?}). The two
279
+ # readers split the overloaded `error` payload field by type.
280
+ # @return [String, nil]
281
+ #
282
+ def error
283
+ value = self["error"]
284
+ value if value.is_a?(::String)
285
+ end
286
+
287
+ ##
288
+ # The incremental assistant text on a `message.delta` event.
289
+ # @return [String, nil]
290
+ #
291
+ def delta
292
+ self["delta"]
293
+ end
294
+
295
+ ##
296
+ # The full reasoning text on a `reasoning.available` event.
297
+ # @return [String, nil]
298
+ #
299
+ def text
300
+ self["text"]
301
+ end
302
+
303
+ ##
304
+ # The assembled final assistant text on a `run.completed` event.
305
+ # @return [String, nil]
306
+ #
307
+ def output
308
+ self["output"]
309
+ end
310
+
311
+ ##
312
+ # The token usage on a `run.completed` event, wrapped in a {RunUsage}.
313
+ # Returns `nil` when the field is absent.
314
+ # @return [RunUsage, nil]
315
+ #
316
+ def usage
317
+ raw = self["usage"]
318
+ raw.is_a?(::Hash) ? RunUsage.new(raw) : nil
319
+ end
320
+
321
+ ##
322
+ # The command awaiting approval on an `approval.request` event.
323
+ # @return [String, nil]
324
+ #
325
+ def command
326
+ self["command"]
327
+ end
328
+
329
+ ##
330
+ # The matched approval pattern key on an `approval.request` event.
331
+ # @return [String, nil]
332
+ #
333
+ def pattern_key
334
+ self["pattern_key"]
335
+ end
336
+
337
+ ##
338
+ # All matched approval pattern keys on an `approval.request` event.
339
+ # Returns `nil` when the field is absent.
340
+ # @return [Array<String>, nil]
341
+ #
342
+ def pattern_keys
343
+ self["pattern_keys"]
344
+ end
345
+
346
+ ##
347
+ # A human-readable description of the gated command on an
348
+ # `approval.request` event.
349
+ # @return [String, nil]
350
+ #
351
+ def description
352
+ self["description"]
353
+ end
354
+
355
+ ##
356
+ # The valid approval choices on an `approval.request` event (e.g.
357
+ # `["once", "session", "always", "deny"]`). Returns `nil` when the field
358
+ # is absent.
359
+ # @return [Array<String>, nil]
360
+ #
361
+ def choices
362
+ self["choices"]
363
+ end
364
+
365
+ ##
366
+ # The choice that resolved an approval on an `approval.responded` event.
367
+ # @return [String, nil]
368
+ #
369
+ def choice
370
+ self["choice"]
371
+ end
372
+
373
+ ##
374
+ # The count of approvals resolved on an `approval.responded` event.
375
+ # @return [Integer, nil]
376
+ #
377
+ def resolved
378
+ self["resolved"]
379
+ end
380
+ end
381
+
382
+ ##
383
+ # The acknowledgement returned by responding to a run's pending approval
384
+ # ({Resources::Runs#respond_approval}): `{object:
385
+ # "hermes.run.approval_response", run_id, choice, resolved}`. The run then
386
+ # resumes (the gated tool executes on an approve, or is aborted on a
387
+ # deny — though a denied run still ends `completed`); observe the
388
+ # `approval.responded` and `tool.completed` frames on the event stream, or
389
+ # poll {Resources::Runs#get}, for the outcome.
390
+ #
391
+ class RunApprovalResponse < Entity
392
+ ##
393
+ # The object type, `"hermes.run.approval_response"`.
394
+ # @return [String, nil]
395
+ #
396
+ def object
397
+ self["object"]
398
+ end
399
+
400
+ ##
401
+ # The id of the run whose approval was answered (`"run_…"`).
402
+ # @return [String, nil]
403
+ #
404
+ def run_id
405
+ self["run_id"]
406
+ end
407
+
408
+ ##
409
+ # The choice that was submitted: `"once"`, `"session"`, `"always"`, or
410
+ # `"deny"`.
411
+ # @return [String, nil]
412
+ #
413
+ def choice
414
+ self["choice"]
415
+ end
416
+
417
+ ##
418
+ # The number of pending approvals resolved by the response.
419
+ # @return [Integer, nil]
420
+ #
421
+ def resolved
422
+ self["resolved"]
423
+ end
424
+ end
425
+ end
426
+ end
427
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hermes_agent/client/entity"
4
+
5
+ module HermesAgent
6
+ class Client
7
+ module Entities
8
+ ##
9
+ # Mixin for entities that carry the server's session-continuity headers
10
+ # (`X-Hermes-Session-ID` / `X-Hermes-Session-Key`) alongside their JSON
11
+ # body. Included by the entities returned from endpoints that surface
12
+ # those headers ({ChatCompletion}, {Response}).
13
+ #
14
+ # The session values come from response *headers*, not the JSON body, so
15
+ # they are stored separately from the wrapped payload: {Entity#to_h} and
16
+ # {Entity#[]} continue to reflect only the body. They do, however,
17
+ # participate in equality ({#==} / `#eql?`) and {#hash}, so two entities
18
+ # with the same body but different sessions are not equal.
19
+ #
20
+ module SessionHeaders
21
+ ##
22
+ # Wrap a parsed JSON payload, plus the session headers from the response
23
+ # that produced it.
24
+ #
25
+ # @param data [Hash] The parsed response body, with string keys.
26
+ # @param session_id [String, nil] The `X-Hermes-Session-ID` header
27
+ # value, or `nil` if the response did not carry one.
28
+ # @param session_key [String, nil] The `X-Hermes-Session-Key` header
29
+ # value, or `nil` if the response did not carry one.
30
+ #
31
+ # @private
32
+ def initialize(data, session_id: nil, session_key: nil)
33
+ @session_id = session_id
34
+ @session_key = session_key
35
+ super(data)
36
+ end
37
+
38
+ ##
39
+ # The session id from the response's `X-Hermes-Session-ID` header. The
40
+ # server always returns one (generating a fresh id when the request did
41
+ # not supply a session), except where the endpoint omits the header
42
+ # entirely (e.g. retrieving a response by id), in which case it is `nil`.
43
+ # @return [String, nil]
44
+ #
45
+ attr_reader :session_id
46
+
47
+ ##
48
+ # The session key from the response's `X-Hermes-Session-Key` header.
49
+ # Present only when a session key was supplied on the request;
50
+ # otherwise `nil`.
51
+ # @return [String, nil]
52
+ #
53
+ attr_reader :session_key
54
+
55
+ ##
56
+ # Whether this entity equals another: same class, equal body payload,
57
+ # and equal session id/key.
58
+ #
59
+ # @param other [Object] The object to compare against.
60
+ # @return [boolean]
61
+ #
62
+ def ==(other)
63
+ super && other.session_id == @session_id && other.session_key == @session_key
64
+ end
65
+
66
+ ##
67
+ # A hash code consistent with {#==} and `#eql?`, incorporating the
68
+ # session values.
69
+ #
70
+ # @return [Integer]
71
+ #
72
+ def hash
73
+ [super, @session_id, @session_key].hash
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HermesAgent
4
+ class Client
5
+ ##
6
+ # Base class for lightweight wrappers around parsed JSON payloads.
7
+ #
8
+ # Subclasses add method readers for the fields we have mapped, but the raw
9
+ # parsed payload is always available as the source of truth: {#to_h}
10
+ # returns the full hash and {#[]} reads an individual key.
11
+ #
12
+ # Entities are immutable value objects: both the entity and its underlying
13
+ # payload are frozen on construction, and equality ({#==} / {#eql?} /
14
+ # {#hash}) is by class and payload, so entities can be compared and used as
15
+ # Hash keys.
16
+ #
17
+ class Entity
18
+ ##
19
+ # Wrap a parsed JSON payload. The payload and the entity are frozen.
20
+ #
21
+ # A `nil` `data` is coerced to an empty hash, so an entity built from a
22
+ # missing envelope (e.g. a response that lacks the nested key a resource
23
+ # extracts) degrades to a reader-returns-`nil` entity rather than raising
24
+ # an opaque `NoMethodError` on the first read. This matches the
25
+ # "best-effort readers over a frozen payload" model the readers already
26
+ # use for absent fields. A non-`nil`, non-Hash payload is kept as-is (the
27
+ # streaming path wraps arbitrary parsed JSON, including arrays, in the
28
+ # base entity and reads it back via {#to_h}).
29
+ #
30
+ # @param data [Hash] The parsed response body, with string keys.
31
+ #
32
+ # @private
33
+ def initialize(data)
34
+ @data = (data.nil? ? {} : data).freeze
35
+ freeze
36
+ end
37
+
38
+ ##
39
+ # Read a raw field by its server-side (string) key.
40
+ #
41
+ # @param key [String] The field name.
42
+ # @return [Object, nil] The raw value, or `nil` if absent.
43
+ #
44
+ def [](key)
45
+ @data[key]
46
+ end
47
+
48
+ ##
49
+ # The full parsed payload (frozen).
50
+ #
51
+ # @return [Hash] The raw response body with string keys.
52
+ #
53
+ def to_h
54
+ @data
55
+ end
56
+
57
+ ##
58
+ # Whether this entity equals another: true when `other` is an instance of
59
+ # the same class wrapping equal payload data.
60
+ #
61
+ # @param other [Object] The object to compare against.
62
+ # @return [boolean]
63
+ #
64
+ def ==(other)
65
+ other.instance_of?(self.class) && other.to_h == @data
66
+ end
67
+
68
+ ##
69
+ # Alias of {#==}, so entities behave consistently as Hash keys (paired
70
+ # with {#hash}).
71
+ #
72
+ # @param other [Object] The object to compare against.
73
+ # @return [boolean]
74
+ #
75
+ def eql?(other)
76
+ self == other
77
+ end
78
+
79
+ ##
80
+ # A hash code consistent with {#==} and {#eql?}.
81
+ #
82
+ # @return [Integer]
83
+ #
84
+ def hash
85
+ [self.class, @data].hash
86
+ end
87
+ end
88
+ end
89
+ end