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,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