open-agent-guide 0.5.0__py3-none-any.whl
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.
- oag_client/__init__.py +8 -0
- oag_client/catalog.py +15 -0
- oag_client/cli.py +425 -0
- oag_client/client.py +273 -0
- oag_client/journal.py +110 -0
- oag_client/journal_lock.py +49 -0
- oag_client/pacing.py +234 -0
- oag_client/py.typed +0 -0
- oag_client/reads.py +187 -0
- oag_client/recovery.py +39 -0
- oag_client/render.py +177 -0
- oag_client/transport.py +101 -0
- oag_client/types.py +112 -0
- oag_client/workflows.py +432 -0
- open_agent_guide-0.5.0.dist-info/METADATA +330 -0
- open_agent_guide-0.5.0.dist-info/RECORD +19 -0
- open_agent_guide-0.5.0.dist-info/WHEEL +4 -0
- open_agent_guide-0.5.0.dist-info/entry_points.txt +2 -0
- open_agent_guide-0.5.0.dist-info/licenses/LICENSE +21 -0
oag_client/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Submission workflows without a Django or CLI dependency at import time."""
|
|
2
|
+
|
|
3
|
+
from .client import Client
|
|
4
|
+
from .pacing import SharedPacer
|
|
5
|
+
from .pacing import shared_pacer
|
|
6
|
+
from .reads import Reader
|
|
7
|
+
|
|
8
|
+
__all__ = ["Client", "Reader", "SharedPacer", "shared_pacer"]
|
oag_client/catalog.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Read catalog guidance through the shared client's pacing and timeout policy."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from .client import Client
|
|
6
|
+
from .types import Response
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def coverage(client: Client, product: str, *, deadline: float) -> Response:
|
|
10
|
+
"""Return the server's report unchanged using a catalog-root client."""
|
|
11
|
+
return client.request(
|
|
12
|
+
"GET",
|
|
13
|
+
f"/api/v1/catalog/products/{quote(product, safe='')}/coverage/",
|
|
14
|
+
deadline=deadline,
|
|
15
|
+
)
|
oag_client/cli.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Argument parsing and process exits only; workflows are in-process APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from http import HTTPStatus
|
|
10
|
+
from importlib.metadata import version as distribution_version
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from .catalog import coverage as read_coverage
|
|
18
|
+
from .client import Client
|
|
19
|
+
from .journal import Journal
|
|
20
|
+
from .journal import fingerprint
|
|
21
|
+
from .pacing import READ_SPACING
|
|
22
|
+
from .pacing import shared_pacer
|
|
23
|
+
from .reads import Reader
|
|
24
|
+
from .recovery import reconcile as reconcile_row
|
|
25
|
+
from .render import Output
|
|
26
|
+
from .render import guidance_hint
|
|
27
|
+
from .render import render
|
|
28
|
+
from .render import render_coverage
|
|
29
|
+
from .render import render_coverage_failure
|
|
30
|
+
from .transport import HTTPTransport
|
|
31
|
+
from .types import ClientError
|
|
32
|
+
from .types import Object
|
|
33
|
+
from .types import failure_detail
|
|
34
|
+
from .types import http_failure
|
|
35
|
+
from .types import submission_id
|
|
36
|
+
from .workflows import DEFAULT_TERMINAL_WATCH_SECONDS
|
|
37
|
+
from .workflows import exit_code
|
|
38
|
+
from .workflows import failed_read
|
|
39
|
+
from .workflows import payloads
|
|
40
|
+
from .workflows import retire as retire_batch
|
|
41
|
+
from .workflows import status as read_status
|
|
42
|
+
from .workflows import submit as submit_batch
|
|
43
|
+
from .workflows import validate as validate_batch
|
|
44
|
+
from .workflows import watch as watch_batch
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from collections.abc import Callable
|
|
48
|
+
|
|
49
|
+
app = typer.Typer(
|
|
50
|
+
no_args_is_help=True,
|
|
51
|
+
pretty_exceptions_enable=False,
|
|
52
|
+
add_completion=False,
|
|
53
|
+
)
|
|
54
|
+
OutputOption = Annotated[Output, typer.Option("--output", "-o")]
|
|
55
|
+
TimeoutOption = Annotated[float, typer.Option("--timeout", min=0.01)]
|
|
56
|
+
JournalOption = Annotated[Path, typer.Option("--journal")]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def version_callback(value: bool) -> None: # noqa: FBT001 -- Typer invokes an option callback with the parsed value positionally, so the parameter cannot be keyword-only
|
|
60
|
+
if value:
|
|
61
|
+
typer.echo(f"oag {distribution_version('open-agent-guide')}")
|
|
62
|
+
raise typer.Exit(0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.callback()
|
|
66
|
+
def root(
|
|
67
|
+
*,
|
|
68
|
+
version: Annotated[
|
|
69
|
+
bool,
|
|
70
|
+
typer.Option(
|
|
71
|
+
"--version",
|
|
72
|
+
"-V",
|
|
73
|
+
help="Show the version and exit.",
|
|
74
|
+
callback=version_callback,
|
|
75
|
+
is_eager=True,
|
|
76
|
+
),
|
|
77
|
+
] = False,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Validate, submit, and watch immutable catalog proposals."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def require_token(token: str) -> None:
|
|
83
|
+
if not token:
|
|
84
|
+
msg = "OAG_TOKEN is required"
|
|
85
|
+
raise ClientError(msg)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run(
|
|
89
|
+
operation: Callable[[Client, str], int],
|
|
90
|
+
*,
|
|
91
|
+
catalog: bool = False,
|
|
92
|
+
) -> None:
|
|
93
|
+
token = os.environ.get("OAG_TOKEN", "")
|
|
94
|
+
transport: HTTPTransport | None = None
|
|
95
|
+
try:
|
|
96
|
+
if not catalog:
|
|
97
|
+
require_token(token)
|
|
98
|
+
transport = (
|
|
99
|
+
HTTPTransport(token, allow_anonymous=True)
|
|
100
|
+
if catalog
|
|
101
|
+
else HTTPTransport(token)
|
|
102
|
+
)
|
|
103
|
+
base_url = os.environ.get("OAG_BASE_URL", "https://www.openagentguide.com")
|
|
104
|
+
# Coordinate pacing with any other processes on this host sharing the token,
|
|
105
|
+
# keyed by origin and a one-way token fingerprint (issue #1166).
|
|
106
|
+
pacer = shared_pacer(
|
|
107
|
+
base_url,
|
|
108
|
+
token,
|
|
109
|
+
read_spacing=float(os.environ.get("OAG_READ_SPACING", READ_SPACING)),
|
|
110
|
+
)
|
|
111
|
+
client = Client(
|
|
112
|
+
transport,
|
|
113
|
+
base_url,
|
|
114
|
+
pacer=pacer,
|
|
115
|
+
api_root="/api/v1/catalog/" if catalog else "/api/v1/submissions/",
|
|
116
|
+
)
|
|
117
|
+
code = operation(client, hashlib.sha256(token.encode()).hexdigest())
|
|
118
|
+
except (ClientError, ValueError, OSError) as exc:
|
|
119
|
+
# Paths and input JSON may contain secrets; only ClientError is display-safe.
|
|
120
|
+
message = (
|
|
121
|
+
str(exc)
|
|
122
|
+
if isinstance(exc, ClientError)
|
|
123
|
+
else "Invalid input or inaccessible file"
|
|
124
|
+
)
|
|
125
|
+
typer.echo(message.replace(token, "[REDACTED]") if token else message, err=True)
|
|
126
|
+
code = 2
|
|
127
|
+
except KeyboardInterrupt:
|
|
128
|
+
typer.echo("Cancelled; resume with the same journal", err=True)
|
|
129
|
+
code = 130
|
|
130
|
+
finally:
|
|
131
|
+
if transport is not None:
|
|
132
|
+
transport.close()
|
|
133
|
+
raise typer.Exit(code)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def read_input(file: str) -> list[Object]:
|
|
137
|
+
return payloads(sys.stdin.read() if file == "-" else Path(file).read_text())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def emit(
|
|
141
|
+
records: list[Object],
|
|
142
|
+
output: Output,
|
|
143
|
+
*,
|
|
144
|
+
require_all: bool = False,
|
|
145
|
+
cancelled: bool = False,
|
|
146
|
+
) -> int:
|
|
147
|
+
typer.echo(render(records, output, cancelled=cancelled))
|
|
148
|
+
return 130 if cancelled else exit_code(records, require_all_merged=require_all)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command()
|
|
152
|
+
def validate(
|
|
153
|
+
file: str,
|
|
154
|
+
output: OutputOption = Output.TEXT,
|
|
155
|
+
timeout: TimeoutOption = 300,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Validate a JSON object/array from FILE, or '-' for stdin, on the server."""
|
|
158
|
+
|
|
159
|
+
def execute(client: Client, account: str) -> int:
|
|
160
|
+
records = validate_batch(
|
|
161
|
+
client,
|
|
162
|
+
read_input(file),
|
|
163
|
+
deadline=client.clock.monotonic() + timeout,
|
|
164
|
+
)
|
|
165
|
+
return emit(records, output)
|
|
166
|
+
|
|
167
|
+
run(execute)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command()
|
|
171
|
+
def submit(
|
|
172
|
+
file: str,
|
|
173
|
+
journal: JournalOption,
|
|
174
|
+
output: OutputOption = Output.TEXT,
|
|
175
|
+
timeout: TimeoutOption = 300,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Submit proposals; safely retry keyed uncertain rows using the same journal."""
|
|
178
|
+
|
|
179
|
+
def execute(client: Client, account: str) -> int:
|
|
180
|
+
rows = read_input(file)
|
|
181
|
+
identity: Object = {
|
|
182
|
+
"kind": "submit",
|
|
183
|
+
"base_url": client.base_url,
|
|
184
|
+
"account": account,
|
|
185
|
+
"input": fingerprint(rows),
|
|
186
|
+
}
|
|
187
|
+
with Journal(journal, identity) as store:
|
|
188
|
+
records = submit_batch(
|
|
189
|
+
client,
|
|
190
|
+
rows,
|
|
191
|
+
store,
|
|
192
|
+
deadline=client.clock.monotonic() + timeout,
|
|
193
|
+
)
|
|
194
|
+
return emit(records, output)
|
|
195
|
+
|
|
196
|
+
run(execute)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command()
|
|
200
|
+
def status(
|
|
201
|
+
identifier: str,
|
|
202
|
+
output: OutputOption = Output.TEXT,
|
|
203
|
+
timeout: TimeoutOption = 60,
|
|
204
|
+
*,
|
|
205
|
+
require_all_merged: bool = False,
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Read one submission and all linked evidence once."""
|
|
208
|
+
|
|
209
|
+
def execute(client: Client, account: str) -> int:
|
|
210
|
+
parsed_id = submission_id(identifier)
|
|
211
|
+
try:
|
|
212
|
+
record = read_status(
|
|
213
|
+
client,
|
|
214
|
+
parsed_id,
|
|
215
|
+
deadline=client.clock.monotonic() + timeout,
|
|
216
|
+
)
|
|
217
|
+
except ClientError as exc:
|
|
218
|
+
record = failed_read(parsed_id, exc)
|
|
219
|
+
return emit([record], output, require_all=require_all_merged)
|
|
220
|
+
|
|
221
|
+
run(execute)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.command()
|
|
225
|
+
def watch( # noqa: PLR0913 -- Each parameter is a separate public CLI option.
|
|
226
|
+
journal: JournalOption,
|
|
227
|
+
identifiers: Annotated[list[str] | None, typer.Argument()] = None,
|
|
228
|
+
file: Annotated[Path | None, typer.Option("--file")] = None,
|
|
229
|
+
output: OutputOption = Output.TEXT,
|
|
230
|
+
timeout: TimeoutOption = DEFAULT_TERMINAL_WATCH_SECONDS,
|
|
231
|
+
*,
|
|
232
|
+
require_all_merged: bool = False,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Poll up to 100 IDs per request; --file reads one UUID per line."""
|
|
235
|
+
|
|
236
|
+
def execute(client: Client, account: str) -> int:
|
|
237
|
+
values = [*(identifiers or []), *(file.read_text().split() if file else [])]
|
|
238
|
+
ids = list(dict.fromkeys(submission_id(value) for value in values))
|
|
239
|
+
if not ids:
|
|
240
|
+
msg = "Provide submission IDs or --file"
|
|
241
|
+
raise ClientError(msg)
|
|
242
|
+
identity: Object = {
|
|
243
|
+
"kind": "watch",
|
|
244
|
+
"base_url": client.base_url,
|
|
245
|
+
"account": account,
|
|
246
|
+
"ids": list(ids),
|
|
247
|
+
}
|
|
248
|
+
last_hints: dict[str, str] = {}
|
|
249
|
+
|
|
250
|
+
def report_guidance(record: Object) -> None:
|
|
251
|
+
identifier = str(record.get("id", ""))
|
|
252
|
+
hint = guidance_hint(record)
|
|
253
|
+
if hint and last_hints.get(identifier) != hint:
|
|
254
|
+
typer.echo(f"{identifier}: {hint}", err=True)
|
|
255
|
+
last_hints[identifier] = hint
|
|
256
|
+
|
|
257
|
+
with Journal(journal, identity) as store:
|
|
258
|
+
records, cancelled = watch_batch(
|
|
259
|
+
client,
|
|
260
|
+
ids,
|
|
261
|
+
store,
|
|
262
|
+
deadline=client.clock.monotonic() + timeout,
|
|
263
|
+
on_update=report_guidance,
|
|
264
|
+
)
|
|
265
|
+
return emit(
|
|
266
|
+
records,
|
|
267
|
+
output,
|
|
268
|
+
require_all=require_all_merged,
|
|
269
|
+
cancelled=cancelled,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
run(execute)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main() -> None:
|
|
276
|
+
app()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.command()
|
|
280
|
+
def reconcile(
|
|
281
|
+
file: str,
|
|
282
|
+
journal: JournalOption,
|
|
283
|
+
row: Annotated[int, typer.Option(min=0)],
|
|
284
|
+
identifier: Annotated[str, typer.Option("--id")],
|
|
285
|
+
output: OutputOption = Output.TEXT,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Confirm a journal row's ID after manually inspecting the server submission."""
|
|
288
|
+
|
|
289
|
+
def execute(client: Client, account: str) -> int:
|
|
290
|
+
rows = read_input(file)
|
|
291
|
+
identity: Object = {
|
|
292
|
+
"kind": "submit",
|
|
293
|
+
"base_url": client.base_url,
|
|
294
|
+
"account": account,
|
|
295
|
+
"input": fingerprint(rows),
|
|
296
|
+
}
|
|
297
|
+
with Journal(journal, identity) as store:
|
|
298
|
+
record = reconcile_row(
|
|
299
|
+
client,
|
|
300
|
+
store,
|
|
301
|
+
row,
|
|
302
|
+
identifier,
|
|
303
|
+
deadline=client.clock.monotonic() + 60,
|
|
304
|
+
)
|
|
305
|
+
return emit([record], output)
|
|
306
|
+
|
|
307
|
+
run(execute)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def emit_coverage_failure(failure: Object, output: Output) -> int:
|
|
311
|
+
"""Report a coverage read failure consistently with the submission reads.
|
|
312
|
+
|
|
313
|
+
Machine output carries the shared failure envelope on stdout; text keeps the
|
|
314
|
+
diagnostic on stderr. Any read failure exits 3, matching a failed-to-read
|
|
315
|
+
submission record; input and configuration errors still exit 2 via `run`.
|
|
316
|
+
"""
|
|
317
|
+
typer.echo(render_coverage_failure(failure, output), err=output == Output.TEXT)
|
|
318
|
+
return 3
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@app.command()
|
|
322
|
+
def coverage(
|
|
323
|
+
product: str,
|
|
324
|
+
output: OutputOption = Output.TEXT,
|
|
325
|
+
timeout: TimeoutOption = 30,
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Read PRODUCT's coverage gaps and evidence guidance by UUID or slug."""
|
|
328
|
+
|
|
329
|
+
def execute(client: Client, account: str) -> int:
|
|
330
|
+
try:
|
|
331
|
+
response = read_coverage(
|
|
332
|
+
client,
|
|
333
|
+
product,
|
|
334
|
+
deadline=client.clock.monotonic() + timeout,
|
|
335
|
+
)
|
|
336
|
+
except ClientError as exc:
|
|
337
|
+
return emit_coverage_failure(failure_detail(exc), output)
|
|
338
|
+
if response.status != HTTPStatus.OK:
|
|
339
|
+
return emit_coverage_failure(http_failure(response), output)
|
|
340
|
+
typer.echo(render_coverage(response.body, output, status=response.status))
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
run(execute, catalog=True)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@app.command()
|
|
347
|
+
def get(
|
|
348
|
+
path: str,
|
|
349
|
+
timeout: TimeoutOption = 60,
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Read a catalog or submissions read-API PATH through the shared read lane.
|
|
352
|
+
|
|
353
|
+
PATH may be a relative read-API path (`/api/v1/catalog/products/?page=2`) or a
|
|
354
|
+
same-origin absolute URL, so a pagination `next` link can be passed straight
|
|
355
|
+
back without stripping its origin. An absolute PATH is accepted only when its
|
|
356
|
+
scheme and host match `OAG_BASE_URL` with no userinfo; foreign origins,
|
|
357
|
+
unsupported schemes, and disallowed paths exit 2 without a request.
|
|
358
|
+
|
|
359
|
+
The read reserves a slot on the same host-local pacing lane the write commands
|
|
360
|
+
use and honors shared Retry-After cooldowns (issue #1734), so benchmark
|
|
361
|
+
discovery and readbacks stop issuing uncoordinated direct GETs. Catalog reads
|
|
362
|
+
work anonymously; the submissions field contract needs `OAG_TOKEN`. The body is
|
|
363
|
+
printed as JSON; a failed read exits 3 with the server's sanitized envelope and
|
|
364
|
+
an input or configuration error exits 2.
|
|
365
|
+
"""
|
|
366
|
+
token = os.environ.get("OAG_TOKEN", "")
|
|
367
|
+
base_url = os.environ.get("OAG_BASE_URL", "https://www.openagentguide.com")
|
|
368
|
+
reader: Reader | None = None
|
|
369
|
+
try:
|
|
370
|
+
reader = Reader(base_url, token, read_timeout=timeout)
|
|
371
|
+
typer.echo(json.dumps(reader.get(path)))
|
|
372
|
+
code = 0
|
|
373
|
+
except ClientError as exc:
|
|
374
|
+
if exc.failure is not None:
|
|
375
|
+
typer.echo(json.dumps(exc.failure), err=True)
|
|
376
|
+
code = 3
|
|
377
|
+
else:
|
|
378
|
+
message = str(exc).replace(token, "[REDACTED]") if token else str(exc)
|
|
379
|
+
typer.echo(message, err=True)
|
|
380
|
+
code = 2
|
|
381
|
+
finally:
|
|
382
|
+
if reader is not None:
|
|
383
|
+
reader.close()
|
|
384
|
+
raise typer.Exit(code)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@app.command()
|
|
388
|
+
def retire( # noqa: PLR0913 -- Each parameter is a separate public CLI option.
|
|
389
|
+
identifier: str,
|
|
390
|
+
successor: Annotated[
|
|
391
|
+
list[str] | None,
|
|
392
|
+
typer.Option("--successor", help="Newer replacement ID; repeat to supersede."),
|
|
393
|
+
] = None,
|
|
394
|
+
reason: Annotated[str, typer.Option("--reason")] = "",
|
|
395
|
+
output: OutputOption = Output.TEXT,
|
|
396
|
+
timeout: TimeoutOption = 60,
|
|
397
|
+
*,
|
|
398
|
+
force: Annotated[
|
|
399
|
+
bool,
|
|
400
|
+
typer.Option("--force", help="Required to confirm this terminal mutation."),
|
|
401
|
+
] = False,
|
|
402
|
+
) -> None:
|
|
403
|
+
"""Withdraw your own open submission, or supersede it with newer IDs."""
|
|
404
|
+
|
|
405
|
+
def execute(client: Client, account: str) -> int:
|
|
406
|
+
parsed_id = submission_id(identifier)
|
|
407
|
+
successors = [submission_id(value) for value in (successor or [])]
|
|
408
|
+
if not force:
|
|
409
|
+
# Retirement closes an open proposal for good. Gate it behind an
|
|
410
|
+
# explicit flag so the tool never withdraws or supersedes on its own.
|
|
411
|
+
msg = (
|
|
412
|
+
"retire permanently closes an open submission; pass --force to "
|
|
413
|
+
"confirm. This tool never withdraws or supersedes automatically."
|
|
414
|
+
)
|
|
415
|
+
raise ClientError(msg)
|
|
416
|
+
record = retire_batch(
|
|
417
|
+
client,
|
|
418
|
+
parsed_id,
|
|
419
|
+
successor_ids=successors,
|
|
420
|
+
reason=reason,
|
|
421
|
+
deadline=client.clock.monotonic() + timeout,
|
|
422
|
+
)
|
|
423
|
+
return emit([record], output)
|
|
424
|
+
|
|
425
|
+
run(execute)
|