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,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module HermesAgent
|
|
6
|
+
class Client
|
|
7
|
+
##
|
|
8
|
+
# Base class for all errors raised by the client. Rescue this to catch
|
|
9
|
+
# every failure mode the client can produce.
|
|
10
|
+
#
|
|
11
|
+
class Error < ::StandardError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# Raised when the client cannot reach the server at all: a socket, DNS, or
|
|
16
|
+
# TLS failure that produced no HTTP response.
|
|
17
|
+
#
|
|
18
|
+
class ConnectionError < Error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# Raised when a request exceeds the configured open or read timeout.
|
|
23
|
+
#
|
|
24
|
+
class TimeoutError < Error
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# Raised when the server returns a body the client expected to be JSON but
|
|
29
|
+
# could not parse — a malformed payload on an otherwise successful
|
|
30
|
+
# response, or a malformed streamed SSE frame. Distinct from {APIError}:
|
|
31
|
+
# the HTTP request itself succeeded; only the body was unparseable. The
|
|
32
|
+
# unparseable text is available as {#body}, and the underlying
|
|
33
|
+
# `JSON::ParserError` is preserved as the exception's `#cause`.
|
|
34
|
+
#
|
|
35
|
+
class MalformedResponseError < Error
|
|
36
|
+
##
|
|
37
|
+
# Create a malformed-response error.
|
|
38
|
+
#
|
|
39
|
+
# @param message [String] The human-readable error message.
|
|
40
|
+
# @param body [String, nil] The raw text that could not be parsed.
|
|
41
|
+
#
|
|
42
|
+
# @private
|
|
43
|
+
def initialize(message, body: nil)
|
|
44
|
+
super(message)
|
|
45
|
+
@body = body
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# The raw text that could not be parsed.
|
|
50
|
+
# @return [String, nil]
|
|
51
|
+
#
|
|
52
|
+
attr_reader :body
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
##
|
|
56
|
+
# Raised when the server returns a non-2xx HTTP response.
|
|
57
|
+
#
|
|
58
|
+
# The concrete class reflects the HTTP status: {BadRequestError},
|
|
59
|
+
# {AuthenticationError}, {PermissionError}, {NotFoundError},
|
|
60
|
+
# {RateLimitError}, or {ServerError}. A bare {APIError} is raised for any
|
|
61
|
+
# status that maps to none of those.
|
|
62
|
+
#
|
|
63
|
+
# ## Error payloads
|
|
64
|
+
#
|
|
65
|
+
# The server uses three distinct error formats, all of which the client
|
|
66
|
+
# handles when building the error. Application-level errors (authentication,
|
|
67
|
+
# body validation, missing resources on the `/v1` surface) return an
|
|
68
|
+
# OpenAI-style JSON body of the form `{"error": {"message", "type",
|
|
69
|
+
# "param"?, "code"?}}`, and {#error} exposes that inner hash. The jobs
|
|
70
|
+
# surface (`/api/jobs`) instead returns a **flat** `{"error": "<message>"}`
|
|
71
|
+
# for its business errors (`400`/`404`/`500`); the message is still surfaced
|
|
72
|
+
# on the exception message, but {#error} is `nil` (there is no inner hash).
|
|
73
|
+
# Router-level
|
|
74
|
+
# errors (an unrouted path, a wrong method) return a bare text body such as
|
|
75
|
+
# `"404: Not Found"`; for those too {#error} is `nil` and only {#body} is
|
|
76
|
+
# meaningful.
|
|
77
|
+
#
|
|
78
|
+
# Even within the JSON family the field set is inconsistent — `message` and
|
|
79
|
+
# `type` are always present, but `param` and `code` may be null or absent —
|
|
80
|
+
# so treat {#error} entries as best-effort. Do not switch on `type`/`code`
|
|
81
|
+
# to classify a failure (the server returns `type: "invalid_request_error"`
|
|
82
|
+
# even for `401`s); branch on the HTTP {#status} or the error subclass.
|
|
83
|
+
#
|
|
84
|
+
class APIError < Error
|
|
85
|
+
##
|
|
86
|
+
# Build the {APIError} subclass that matches an HTTP error response,
|
|
87
|
+
# extracting the structured payload when the server provides one.
|
|
88
|
+
#
|
|
89
|
+
# @param status [Integer] The HTTP status code.
|
|
90
|
+
# @param body [String] The raw response body.
|
|
91
|
+
# @param headers [Hash] The response headers.
|
|
92
|
+
# @return [APIError] An instance of the subclass matching the status.
|
|
93
|
+
#
|
|
94
|
+
# @private
|
|
95
|
+
def self.from_response(status:, body:, headers: {})
|
|
96
|
+
inner = error_field(body)
|
|
97
|
+
error = inner if inner.is_a?(::Hash)
|
|
98
|
+
message = (error && error["message"]) ||
|
|
99
|
+
(inner if inner.is_a?(::String)) ||
|
|
100
|
+
"Unexpected HTTP status #{status}"
|
|
101
|
+
class_for_status(status).new(message, status: status, body: body,
|
|
102
|
+
headers: headers, error: error)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Pick the {APIError} subclass that represents an HTTP status code.
|
|
106
|
+
def self.class_for_status(status)
|
|
107
|
+
case status
|
|
108
|
+
when 400, 422 then BadRequestError
|
|
109
|
+
when 401 then AuthenticationError
|
|
110
|
+
when 403 then PermissionError
|
|
111
|
+
when 404 then NotFoundError
|
|
112
|
+
when 429 then RateLimitError
|
|
113
|
+
when 500..599 then ServerError
|
|
114
|
+
else APIError
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
private_class_method :class_for_status
|
|
118
|
+
|
|
119
|
+
# Pull the body's top-level `error` field out in a single JSON parse: the
|
|
120
|
+
# inner hash for the OpenAI-style envelope (`{"error":{...}}`) or the flat
|
|
121
|
+
# string for the jobs business errors (`{"error":"..."}`). The caller
|
|
122
|
+
# branches on the returned type. Returns nil when the body carries no
|
|
123
|
+
# `error` field or is a non-JSON (router-level) error body.
|
|
124
|
+
def self.error_field(body)
|
|
125
|
+
parsed = ::JSON.parse(body.to_s)
|
|
126
|
+
parsed["error"] if parsed.is_a?(::Hash)
|
|
127
|
+
rescue ::JSON::ParserError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
private_class_method :error_field
|
|
131
|
+
|
|
132
|
+
##
|
|
133
|
+
# Create an API error. Prefer `from_response`, which selects the correct
|
|
134
|
+
# subclass and parses the payload for you.
|
|
135
|
+
#
|
|
136
|
+
# @param message [String] The human-readable error message.
|
|
137
|
+
# @param status [Integer] The HTTP status code.
|
|
138
|
+
# @param body [String] The raw response body.
|
|
139
|
+
# @param headers [Hash] The response headers.
|
|
140
|
+
# @param error [Hash, nil] The parsed structured error hash, if the body
|
|
141
|
+
# carried one.
|
|
142
|
+
#
|
|
143
|
+
# @private
|
|
144
|
+
def initialize(message, status:, body:, headers: {}, error: nil)
|
|
145
|
+
super(message)
|
|
146
|
+
@status = status
|
|
147
|
+
@body = body
|
|
148
|
+
@headers = headers
|
|
149
|
+
@error = error
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
##
|
|
153
|
+
# The HTTP status code of the error response.
|
|
154
|
+
# @return [Integer]
|
|
155
|
+
#
|
|
156
|
+
attr_reader :status
|
|
157
|
+
|
|
158
|
+
##
|
|
159
|
+
# The raw response body.
|
|
160
|
+
# @return [String]
|
|
161
|
+
#
|
|
162
|
+
attr_reader :body
|
|
163
|
+
|
|
164
|
+
##
|
|
165
|
+
# The response headers, keyed by downcased name (the same normalized
|
|
166
|
+
# shape the success path exposes, so e.g. `headers["retry-after"]` works
|
|
167
|
+
# regardless of the casing the server sent).
|
|
168
|
+
# @return [Hash{String=>String}]
|
|
169
|
+
#
|
|
170
|
+
attr_reader :headers
|
|
171
|
+
|
|
172
|
+
##
|
|
173
|
+
# The structured error payload (the inner `error` object), or `nil` when
|
|
174
|
+
# the server returned a non-JSON body. Field set is best-effort: expect
|
|
175
|
+
# `message` and `type`, but `param` and `code` may be missing.
|
|
176
|
+
# @return [Hash, nil]
|
|
177
|
+
#
|
|
178
|
+
attr_reader :error
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
##
|
|
182
|
+
# Raised on a `400` or `422` response: the request was malformed or failed
|
|
183
|
+
# server-side validation.
|
|
184
|
+
#
|
|
185
|
+
class BadRequestError < APIError
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
##
|
|
189
|
+
# Raised on a `401` response: the bearer token was missing or invalid.
|
|
190
|
+
#
|
|
191
|
+
class AuthenticationError < APIError
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
##
|
|
195
|
+
# Raised on a `403` response: the token is valid but not permitted to
|
|
196
|
+
# perform the request.
|
|
197
|
+
#
|
|
198
|
+
class PermissionError < APIError
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
##
|
|
202
|
+
# Raised on a `404` response: the requested resource does not exist.
|
|
203
|
+
#
|
|
204
|
+
class NotFoundError < APIError
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
##
|
|
208
|
+
# Raised on a `429` response: the request was refused for exceeding a
|
|
209
|
+
# server-side limit. In practice the server raises this only when a new run
|
|
210
|
+
# would exceed its fixed ceiling of concurrent runs; the limit clears as
|
|
211
|
+
# in-flight runs finish, not after a fixed interval.
|
|
212
|
+
#
|
|
213
|
+
# The server sends **no `Retry-After` header or other timing hint**, so the
|
|
214
|
+
# client does not retry automatically — whether and when to retry is left to
|
|
215
|
+
# the caller. Because the limit is concurrency- rather than time-based, a
|
|
216
|
+
# short pause (or simply retrying once an in-flight run completes) is more
|
|
217
|
+
# apt than fixed exponential backoff.
|
|
218
|
+
#
|
|
219
|
+
class RateLimitError < APIError
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
##
|
|
223
|
+
# Raised on a `5xx` response: the server failed to handle the request.
|
|
224
|
+
#
|
|
225
|
+
class ServerError < APIError
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hermes_agent/client/entities/capabilities"
|
|
4
|
+
|
|
5
|
+
module HermesAgent
|
|
6
|
+
class Client
|
|
7
|
+
module Resources
|
|
8
|
+
##
|
|
9
|
+
# The capabilities resource: the server's self-description of the
|
|
10
|
+
# endpoints and features it supports (`/v1/capabilities`).
|
|
11
|
+
#
|
|
12
|
+
class Capabilities
|
|
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
|
+
# Fetch the server's advertised capabilities.
|
|
25
|
+
#
|
|
26
|
+
# @return [Entities::Capabilities] The capabilities document.
|
|
27
|
+
#
|
|
28
|
+
def get
|
|
29
|
+
Entities::Capabilities.new(@transport.get("/v1/capabilities"))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hermes_agent/client/entities/chat_completion"
|
|
4
|
+
require "hermes_agent/client/util"
|
|
5
|
+
|
|
6
|
+
module HermesAgent
|
|
7
|
+
class Client
|
|
8
|
+
module Resources
|
|
9
|
+
##
|
|
10
|
+
# The chat resource: OpenAI-compatible chat completions
|
|
11
|
+
# (`POST /v1/chat/completions`). This endpoint is stateless — each call
|
|
12
|
+
# is independent — and, on a server configured with an API key, requires
|
|
13
|
+
# a bearer token (see {Client} / {Configuration}).
|
|
14
|
+
#
|
|
15
|
+
class Chat
|
|
16
|
+
##
|
|
17
|
+
# The SSE `event:` name of the server's custom tool-progress frames,
|
|
18
|
+
# which are routed to {Entities::ChatToolProgress} rather than treated
|
|
19
|
+
# as completion chunks.
|
|
20
|
+
#
|
|
21
|
+
TOOL_PROGRESS_EVENT = "hermes.tool.progress"
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# Create the resource.
|
|
25
|
+
#
|
|
26
|
+
# @param transport [Transport] The transport used to issue requests.
|
|
27
|
+
#
|
|
28
|
+
# @private
|
|
29
|
+
def initialize(transport)
|
|
30
|
+
@transport = transport
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# Create a chat completion.
|
|
35
|
+
#
|
|
36
|
+
# No `model` is sent: the model is configured server-side and the
|
|
37
|
+
# server ignores a client-supplied one. (A caller who really wants to
|
|
38
|
+
# send fields we have not modeled — including `model` — can pass them
|
|
39
|
+
# through `extra`.)
|
|
40
|
+
#
|
|
41
|
+
# @param messages [Array<Hash>] The OpenAI-style message array. Each
|
|
42
|
+
# message is a hash such as `{role: "user", content: "…"}`;
|
|
43
|
+
# content may include `image_url` parts for inline images.
|
|
44
|
+
# @param session_id [String, nil] A session id to continue, sent as the
|
|
45
|
+
# `X-Hermes-Session-ID` request header. When omitted, the server
|
|
46
|
+
# generates a fresh one (returned on {Entities::SessionHeaders#session_id}).
|
|
47
|
+
# @param session_key [String, nil] A session key, sent as the
|
|
48
|
+
# `X-Hermes-Session-Key` request header.
|
|
49
|
+
# @param idempotency_key [String, nil] An idempotency key, sent as the
|
|
50
|
+
# `Idempotency-Key` request header. The server caches the result for
|
|
51
|
+
# ~5 minutes and replays it for a repeat call carrying the same key
|
|
52
|
+
# and an equivalent request, so a retry does not re-run the model.
|
|
53
|
+
# The replay is **transparent**: the response is indistinguishable
|
|
54
|
+
# from a fresh one (same status, no replay header, and a freshly
|
|
55
|
+
# regenerated `id`), so there is no way — here or in the returned
|
|
56
|
+
# entity — to tell whether a given call was served from the cache.
|
|
57
|
+
# Reusing a key with a *different* request silently recomputes (no
|
|
58
|
+
# error) and overwrites the cached entry. Honored only on this
|
|
59
|
+
# non-streaming endpoint (not on {#stream_create}).
|
|
60
|
+
# @param extra [Hash] Additional request-body fields (e.g. sampling
|
|
61
|
+
# parameters) merged into the body as-is.
|
|
62
|
+
# @return [Entities::ChatCompletion] The completion, carrying the
|
|
63
|
+
# session headers returned by the server.
|
|
64
|
+
# @raise [APIError] If the server returns a non-2xx response.
|
|
65
|
+
#
|
|
66
|
+
def create(messages:, session_id: nil, session_key: nil, idempotency_key: nil, **extra)
|
|
67
|
+
body = {messages: messages, **extra}
|
|
68
|
+
headers = session_request_headers(session_id, session_key)
|
|
69
|
+
headers["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
70
|
+
result = @transport.post("/v1/chat/completions", body, headers: headers)
|
|
71
|
+
Entities::ChatCompletion.new(result.body, **Util.session_headers(result.headers))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
##
|
|
75
|
+
# Create a chat completion, streaming the response.
|
|
76
|
+
#
|
|
77
|
+
# While the server agent executes tools it interleaves custom
|
|
78
|
+
# `hermes.tool.progress` frames; these are surfaced as
|
|
79
|
+
# {Entities::ChatToolProgress} events (distinct from the text
|
|
80
|
+
# {Entities::ChatCompletionChunk}s) and are not folded into the
|
|
81
|
+
# assembled completion.
|
|
82
|
+
#
|
|
83
|
+
# With a block, each event is yielded as it arrives and the assembled
|
|
84
|
+
# {Entities::ChatCompletion} is returned once the stream closes. Without
|
|
85
|
+
# a block, a {Stream} is returned for the caller to iterate; its
|
|
86
|
+
# {Stream#result} is the assembled completion.
|
|
87
|
+
#
|
|
88
|
+
# @param messages [Array<Hash>] The OpenAI-style message array (see
|
|
89
|
+
# {#create}).
|
|
90
|
+
# @param session_id [String, nil] A session id to continue, sent as the
|
|
91
|
+
# `X-Hermes-Session-ID` request header (see {#create}).
|
|
92
|
+
# @param session_key [String, nil] A session key, sent as the
|
|
93
|
+
# `X-Hermes-Session-Key` request header (see {#create}).
|
|
94
|
+
# @param extra [Hash] Additional request-body fields merged into the
|
|
95
|
+
# body as-is.
|
|
96
|
+
# @yieldparam event [Entities::ChatCompletionChunk, Entities::ChatToolProgress]
|
|
97
|
+
# Each streamed event: a text chunk, or a tool-progress frame.
|
|
98
|
+
# @return [Entities::ChatCompletion, Stream] The assembled completion
|
|
99
|
+
# (carrying the server's session headers) when a block is given,
|
|
100
|
+
# otherwise the {Stream}.
|
|
101
|
+
# @raise [APIError] If the server returns a non-2xx response.
|
|
102
|
+
#
|
|
103
|
+
def stream_create(messages:, session_id: nil, session_key: nil, **extra, &block)
|
|
104
|
+
body = {messages: messages, stream: true, **extra}
|
|
105
|
+
result = @transport.stream_post("/v1/chat/completions", body,
|
|
106
|
+
headers: session_request_headers(session_id, session_key))
|
|
107
|
+
session = Util.session_headers(result.headers)
|
|
108
|
+
event_class = lambda do |name|
|
|
109
|
+
name == TOOL_PROGRESS_EVENT ? Entities::ChatToolProgress : Entities::ChatCompletionChunk
|
|
110
|
+
end
|
|
111
|
+
stream = Stream.new(result.body, event_class: event_class, terminator: "[DONE]") do |events|
|
|
112
|
+
Entities::ChatCompletion.from_chunks(events, **session)
|
|
113
|
+
end
|
|
114
|
+
return stream unless block
|
|
115
|
+
|
|
116
|
+
stream.each(&block)
|
|
117
|
+
stream.result
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
##
|
|
123
|
+
# Build the session-continuity request headers, omitting either header
|
|
124
|
+
# the caller did not supply. Returns an empty hash when neither is set.
|
|
125
|
+
#
|
|
126
|
+
# @param session_id [String, nil] The session id, if any.
|
|
127
|
+
# @param session_key [String, nil] The session key, if any.
|
|
128
|
+
# @return [Hash{String=>String}]
|
|
129
|
+
#
|
|
130
|
+
def session_request_headers(session_id, session_key)
|
|
131
|
+
headers = {}
|
|
132
|
+
headers["X-Hermes-Session-ID"] = session_id if session_id
|
|
133
|
+
headers["X-Hermes-Session-Key"] = session_key if session_key
|
|
134
|
+
headers
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hermes_agent/client/entities/health"
|
|
4
|
+
|
|
5
|
+
module HermesAgent
|
|
6
|
+
class Client
|
|
7
|
+
##
|
|
8
|
+
# Resource groups, each exposing the verb methods for one area of the API.
|
|
9
|
+
# Reached through accessors on {Client}, such as {Client#health}.
|
|
10
|
+
#
|
|
11
|
+
module Resources
|
|
12
|
+
##
|
|
13
|
+
# The health resource. Health endpoints live at the server root, not
|
|
14
|
+
# under the `/v1` prefix.
|
|
15
|
+
#
|
|
16
|
+
class Health
|
|
17
|
+
##
|
|
18
|
+
# Create the resource.
|
|
19
|
+
#
|
|
20
|
+
# @param transport [Transport] The transport used to issue requests.
|
|
21
|
+
#
|
|
22
|
+
# @private
|
|
23
|
+
def initialize(transport)
|
|
24
|
+
@transport = transport
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# Check whether the server is healthy.
|
|
29
|
+
#
|
|
30
|
+
# @return [Entities::Health] The health result; {Entities::Health#status}
|
|
31
|
+
# is `"ok"` on a healthy server.
|
|
32
|
+
#
|
|
33
|
+
def check
|
|
34
|
+
Entities::Health.new(@transport.get("/health"))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# Fetch detailed server health, including gateway state, per-platform
|
|
39
|
+
# connection status, and the active-agent count.
|
|
40
|
+
#
|
|
41
|
+
# @return [Entities::HealthDetails] The detailed health result.
|
|
42
|
+
#
|
|
43
|
+
def detailed
|
|
44
|
+
Entities::HealthDetails.new(@transport.get("/health/detailed"))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hermes_agent/client/entities/job"
|
|
4
|
+
|
|
5
|
+
module HermesAgent
|
|
6
|
+
class Client
|
|
7
|
+
module Resources
|
|
8
|
+
##
|
|
9
|
+
# The jobs resource: the Jobs API (`/api/jobs`) for scheduled background
|
|
10
|
+
# work — cron-like recurring tasks, one-shot deferred tasks, and watchdog
|
|
11
|
+
# scripts. On a server configured with an API key, these calls require a
|
|
12
|
+
# bearer token (see {Client} / {Configuration}).
|
|
13
|
+
#
|
|
14
|
+
# Note this resource lives under `/api/jobs`, **not** `/v1`, and is **not**
|
|
15
|
+
# advertised in `/v1/capabilities` — it may be gated, versioned
|
|
16
|
+
# separately, or absent in some builds; confirm against a server that
|
|
17
|
+
# exposes it.
|
|
18
|
+
#
|
|
19
|
+
# **No terminal state to poll.** The server **deletes** a job once it is
|
|
20
|
+
# exhausted: a one-shot (`once`) job is gone after its single run, and a
|
|
21
|
+
# `repeat`-capped job is gone after its final run. After that, {#get} (and
|
|
22
|
+
# {#trigger}) raise {NotFoundError} — a client cannot poll such a job for
|
|
23
|
+
# its outcome once it completes.
|
|
24
|
+
#
|
|
25
|
+
class Jobs
|
|
26
|
+
##
|
|
27
|
+
# Create the resource.
|
|
28
|
+
#
|
|
29
|
+
# @param transport [Transport] The transport used to issue requests.
|
|
30
|
+
#
|
|
31
|
+
# @private
|
|
32
|
+
def initialize(transport)
|
|
33
|
+
@transport = transport
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# List the scheduled jobs. By default the server returns only enabled
|
|
38
|
+
# jobs; pass `include_disabled: true` to include paused/disabled ones.
|
|
39
|
+
# (The list is never paginated — the full set is always returned.)
|
|
40
|
+
#
|
|
41
|
+
# @param include_disabled [Boolean] Whether to include disabled
|
|
42
|
+
# (paused) jobs. Defaults to `false` (enabled jobs only).
|
|
43
|
+
# @return [Array<Entities::Job>] The jobs (empty when there are none).
|
|
44
|
+
# @raise [APIError] If the server returns a non-2xx response.
|
|
45
|
+
#
|
|
46
|
+
def list(include_disabled: false)
|
|
47
|
+
path = include_disabled ? "/api/jobs?include_disabled=true" : "/api/jobs"
|
|
48
|
+
body = @transport.get(path)
|
|
49
|
+
Array(body["jobs"]).map { |raw| Entities::Job.new(raw) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# Create a scheduled job. `name` and `schedule` are required.
|
|
54
|
+
#
|
|
55
|
+
# `schedule` is a string parsed server-side into a `once` / `interval` /
|
|
56
|
+
# `cron` schedule: a bare duration (`"30m"`), an absolute timestamp
|
|
57
|
+
# (`"2027-02-03T14:00:00"`), an interval (`"every 30m"`), or a cron
|
|
58
|
+
# expression (`"0 9 * * *"`). The override slots (`model`, `provider`,
|
|
59
|
+
# `base_url`, `workdir`, `profile`, `context_from`) are **not** writable
|
|
60
|
+
# via this API and so are not parameters — they are silently ignored if
|
|
61
|
+
# sent. A caller who really wants to send unmodeled fields can pass them
|
|
62
|
+
# through `extra`.
|
|
63
|
+
#
|
|
64
|
+
# @param name [String] The job name (required).
|
|
65
|
+
# @param schedule [String] The schedule string (required; see above).
|
|
66
|
+
# @param prompt [String, nil] The task instruction. Omitted when `nil`.
|
|
67
|
+
# @param repeat [Integer, nil] The maximum number of runs (`nil` =
|
|
68
|
+
# unbounded). Omitted when `nil`.
|
|
69
|
+
# @param deliver [String, nil] The delivery target (defaults server-side
|
|
70
|
+
# to `"local"`). Omitted when `nil`. Not validated on write.
|
|
71
|
+
# @param skills [Array<String>, nil] Attached skill names. Omitted when
|
|
72
|
+
# `nil`.
|
|
73
|
+
# @param script [String, nil] A script path under `~/.hermes/scripts/`.
|
|
74
|
+
# Omitted when `nil`. **Note:** the reference gateway's create
|
|
75
|
+
# handler silently drops this field (it stays `null`); kept as a
|
|
76
|
+
# parameter for forward-compatibility and other deployments.
|
|
77
|
+
# @param no_agent [Boolean, nil] Whether to skip the LLM and deliver the
|
|
78
|
+
# script's stdout verbatim. Omitted when `nil` (so `false` is sent).
|
|
79
|
+
# **Note:** like `script`, silently dropped by the reference
|
|
80
|
+
# gateway's create handler (stays `false`).
|
|
81
|
+
# @param extra [Hash] Additional request-body fields merged in as-is.
|
|
82
|
+
# @return [Entities::Job] The created job.
|
|
83
|
+
# @raise [BadRequestError] On a missing `name`/`schedule` (`400`).
|
|
84
|
+
# @raise [ServerError] On an **unparseable `schedule`** — the server
|
|
85
|
+
# returns `500`, not `400`, even though it is really invalid input.
|
|
86
|
+
# @raise [APIError] If the server returns another non-2xx response.
|
|
87
|
+
#
|
|
88
|
+
def create(name:, schedule:, prompt: nil, repeat: nil, deliver: nil,
|
|
89
|
+
skills: nil, script: nil, no_agent: nil, **extra)
|
|
90
|
+
body = {name: name, schedule: schedule, **extra}
|
|
91
|
+
body[:prompt] = prompt unless prompt.nil?
|
|
92
|
+
body[:repeat] = repeat unless repeat.nil?
|
|
93
|
+
body[:deliver] = deliver unless deliver.nil?
|
|
94
|
+
body[:skills] = skills unless skills.nil?
|
|
95
|
+
body[:script] = script unless script.nil?
|
|
96
|
+
body[:no_agent] = no_agent unless no_agent.nil?
|
|
97
|
+
Entities::Job.new(@transport.post("/api/jobs", body).body["job"])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
##
|
|
101
|
+
# Retrieve a job by id.
|
|
102
|
+
#
|
|
103
|
+
# @param job_id [String] The job id (12 hex characters).
|
|
104
|
+
# @return [Entities::Job] The current job state.
|
|
105
|
+
# @raise [NotFoundError] If no such job exists — including a job the
|
|
106
|
+
# server already deleted because it was exhausted (a `once` job after
|
|
107
|
+
# its run, or a capped job after its final run).
|
|
108
|
+
# @raise [APIError] If the server returns another non-2xx response.
|
|
109
|
+
#
|
|
110
|
+
def get(job_id)
|
|
111
|
+
Entities::Job.new(@transport.get("/api/jobs/#{job_id}")["job"])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
##
|
|
115
|
+
# Update a job (a partial merge over the writable fields). Sent fields
|
|
116
|
+
# are merged onto the existing job; a sent `schedule` is re-parsed and
|
|
117
|
+
# `next_run_at` recomputed. The override slots are **not** writable here
|
|
118
|
+
# either (silently ignored), so they are not parameters.
|
|
119
|
+
#
|
|
120
|
+
# @param job_id [String] The job id (12 hex characters).
|
|
121
|
+
# @param name [String, nil] A new name. Omitted when `nil`.
|
|
122
|
+
# @param schedule [String, nil] A new schedule string (re-parsed).
|
|
123
|
+
# Omitted when `nil`.
|
|
124
|
+
# @param prompt [String, nil] A new prompt. Omitted when `nil`.
|
|
125
|
+
# @param repeat [Integer, nil] A new run cap. Omitted when `nil`.
|
|
126
|
+
# @param deliver [String, nil] A new delivery target. Omitted when `nil`.
|
|
127
|
+
# @param skills [Array<String>, nil] New skill names. Omitted when `nil`.
|
|
128
|
+
# @param script [String, nil] A new script path. Omitted when `nil`.
|
|
129
|
+
# **Note:** silently dropped by the reference gateway (see {#create}).
|
|
130
|
+
# @param no_agent [Boolean, nil] A new `no_agent` flag. Omitted when
|
|
131
|
+
# `nil`. **Note:** silently dropped by the reference gateway (see
|
|
132
|
+
# {#create}).
|
|
133
|
+
# @param extra [Hash] Additional request-body fields merged in as-is.
|
|
134
|
+
# @return [Entities::Job] The updated job.
|
|
135
|
+
# @raise [NotFoundError] If no such job exists.
|
|
136
|
+
# @raise [ServerError] On an **unparseable `schedule`** (`500`, not
|
|
137
|
+
# `400`; see {#create}).
|
|
138
|
+
# @raise [APIError] If the server returns another non-2xx response.
|
|
139
|
+
#
|
|
140
|
+
def update(job_id, name: nil, schedule: nil, prompt: nil, repeat: nil,
|
|
141
|
+
deliver: nil, skills: nil, script: nil, no_agent: nil, **extra)
|
|
142
|
+
body = {**extra}
|
|
143
|
+
body[:name] = name unless name.nil?
|
|
144
|
+
body[:schedule] = schedule unless schedule.nil?
|
|
145
|
+
body[:prompt] = prompt unless prompt.nil?
|
|
146
|
+
body[:repeat] = repeat unless repeat.nil?
|
|
147
|
+
body[:deliver] = deliver unless deliver.nil?
|
|
148
|
+
body[:skills] = skills unless skills.nil?
|
|
149
|
+
body[:script] = script unless script.nil?
|
|
150
|
+
body[:no_agent] = no_agent unless no_agent.nil?
|
|
151
|
+
Entities::Job.new(@transport.patch("/api/jobs/#{job_id}", body)["job"])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
##
|
|
155
|
+
# Delete a job (also cancels any in-flight run).
|
|
156
|
+
#
|
|
157
|
+
# @param job_id [String] The job id (12 hex characters).
|
|
158
|
+
# @return [Boolean] `true` (mapped from the server's `{"ok": true}`).
|
|
159
|
+
# @raise [NotFoundError] If no such job exists.
|
|
160
|
+
# @raise [APIError] If the server returns another non-2xx response.
|
|
161
|
+
#
|
|
162
|
+
def delete(job_id)
|
|
163
|
+
@transport.delete("/api/jobs/#{job_id}")["ok"] == true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
##
|
|
167
|
+
# Pause a job without deleting it (sets `enabled: false`). Idempotent:
|
|
168
|
+
# pausing an already-paused job is not an error.
|
|
169
|
+
#
|
|
170
|
+
# @param job_id [String] The job id (12 hex characters).
|
|
171
|
+
# @return [Entities::Job] The paused job.
|
|
172
|
+
# @raise [NotFoundError] If no such job exists.
|
|
173
|
+
# @raise [APIError] If the server returns another non-2xx response.
|
|
174
|
+
#
|
|
175
|
+
def pause(job_id)
|
|
176
|
+
Entities::Job.new(@transport.post("/api/jobs/#{job_id}/pause", {}).body["job"])
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
##
|
|
180
|
+
# Resume a paused job (recomputes `next_run_at` from the resume time).
|
|
181
|
+
# Idempotent: resuming an already-scheduled job is not an error.
|
|
182
|
+
#
|
|
183
|
+
# @param job_id [String] The job id (12 hex characters).
|
|
184
|
+
# @return [Entities::Job] The resumed job.
|
|
185
|
+
# @raise [NotFoundError] If no such job exists.
|
|
186
|
+
# @raise [APIError] If the server returns another non-2xx response.
|
|
187
|
+
#
|
|
188
|
+
def resume(job_id)
|
|
189
|
+
Entities::Job.new(@transport.post("/api/jobs/#{job_id}/resume", {}).body["job"])
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
##
|
|
193
|
+
# Trigger a job to run out of schedule.
|
|
194
|
+
#
|
|
195
|
+
# This is **asynchronous**: it advances the job's `next_run_at` to "now"
|
|
196
|
+
# so the scheduler picks it up on its next tick, then returns the job
|
|
197
|
+
# immediately — it does **not** block on or return the run's result, and
|
|
198
|
+
# `last_run_at` / `last_status` / `repeat.completed` are not yet updated
|
|
199
|
+
# when it returns. (For a one-shot or final-run job, the job may be
|
|
200
|
+
# deleted once it fires, so it cannot be polled afterward — see {#get}.)
|
|
201
|
+
#
|
|
202
|
+
# @param job_id [String] The job id (12 hex characters).
|
|
203
|
+
# @return [Entities::Job] The job, with `next_run_at` advanced.
|
|
204
|
+
# @raise [NotFoundError] If no such job exists.
|
|
205
|
+
# @raise [APIError] If the server returns another non-2xx response.
|
|
206
|
+
#
|
|
207
|
+
def trigger(job_id)
|
|
208
|
+
Entities::Job.new(@transport.post("/api/jobs/#{job_id}/run", {}).body["job"])
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|