parcha-agentrun 0.2.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.
- agentrun/__init__.py +129 -0
- agentrun/_batch.py +86 -0
- agentrun/_http.py +184 -0
- agentrun/_ops.py +451 -0
- agentrun/_poll.py +61 -0
- agentrun/_version.py +1 -0
- agentrun/agent.py +833 -0
- agentrun/client.py +695 -0
- agentrun/errors.py +164 -0
- agentrun/models.py +498 -0
- agentrun/py.typed +0 -0
- parcha_agentrun-0.2.0.dist-info/METADATA +243 -0
- parcha_agentrun-0.2.0.dist-info/RECORD +14 -0
- parcha_agentrun-0.2.0.dist-info/WHEEL +4 -0
agentrun/__init__.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""AgentRun Python SDK.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from agentrun import Agent
|
|
6
|
+
|
|
7
|
+
agent = Agent(name="Verifier", instructions="Verify the company and cite evidence.")
|
|
8
|
+
run = agent.run("Verify acme.example")
|
|
9
|
+
result = agent.wait(run.id)
|
|
10
|
+
print(result.status, result.markdown)
|
|
11
|
+
|
|
12
|
+
``Agent`` reads ``AGENTRUN_API_KEY`` from the environment. Use ``AgentRun``
|
|
13
|
+
for direct access to every resource and ``AsyncAgent`` / ``AsyncAgentRun``
|
|
14
|
+
under asyncio or trio.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from ._batch import BatchCSV, parse_batch_csv
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .agent import Agent, AsyncAgent, Skill
|
|
20
|
+
from .client import AgentRun, AsyncAgentRun
|
|
21
|
+
from .errors import (
|
|
22
|
+
AgentRunError,
|
|
23
|
+
APIConnectionError,
|
|
24
|
+
APIError,
|
|
25
|
+
APITimeoutError,
|
|
26
|
+
AuthenticationError,
|
|
27
|
+
BuildError,
|
|
28
|
+
ConflictError,
|
|
29
|
+
InternalServerError,
|
|
30
|
+
InvalidResponseError,
|
|
31
|
+
NotFoundError,
|
|
32
|
+
PermissionDeniedError,
|
|
33
|
+
PollTimeoutError,
|
|
34
|
+
RateLimitError,
|
|
35
|
+
ServiceUnavailableError,
|
|
36
|
+
UnprocessableEntityError,
|
|
37
|
+
)
|
|
38
|
+
from .models import (
|
|
39
|
+
AgentBuild,
|
|
40
|
+
AgentRecord,
|
|
41
|
+
AgentSummary,
|
|
42
|
+
Artifact,
|
|
43
|
+
Batch,
|
|
44
|
+
BatchResults,
|
|
45
|
+
BatchRow,
|
|
46
|
+
BatchStatus,
|
|
47
|
+
Campaign,
|
|
48
|
+
CampaignPortfolio,
|
|
49
|
+
CancelResult,
|
|
50
|
+
Effort,
|
|
51
|
+
InputField,
|
|
52
|
+
Integration,
|
|
53
|
+
IntegrationList,
|
|
54
|
+
Loop,
|
|
55
|
+
LoopCadence,
|
|
56
|
+
LoopCreated,
|
|
57
|
+
LoopDetail,
|
|
58
|
+
LoopRun,
|
|
59
|
+
LoopRunNow,
|
|
60
|
+
Page,
|
|
61
|
+
Report,
|
|
62
|
+
RetryResult,
|
|
63
|
+
Run,
|
|
64
|
+
RunStatus,
|
|
65
|
+
RunSummary,
|
|
66
|
+
SkillContent,
|
|
67
|
+
SkillList,
|
|
68
|
+
SkillRecord,
|
|
69
|
+
SkillSummary,
|
|
70
|
+
Usage,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"APIConnectionError",
|
|
75
|
+
"APIError",
|
|
76
|
+
"APITimeoutError",
|
|
77
|
+
"Agent",
|
|
78
|
+
"AgentBuild",
|
|
79
|
+
"AgentRecord",
|
|
80
|
+
"AgentRun",
|
|
81
|
+
"AgentRunError",
|
|
82
|
+
"AgentSummary",
|
|
83
|
+
"Artifact",
|
|
84
|
+
"AsyncAgent",
|
|
85
|
+
"AsyncAgentRun",
|
|
86
|
+
"AuthenticationError",
|
|
87
|
+
"Batch",
|
|
88
|
+
"BatchCSV",
|
|
89
|
+
"BatchResults",
|
|
90
|
+
"BatchRow",
|
|
91
|
+
"BatchStatus",
|
|
92
|
+
"BuildError",
|
|
93
|
+
"Campaign",
|
|
94
|
+
"CampaignPortfolio",
|
|
95
|
+
"CancelResult",
|
|
96
|
+
"ConflictError",
|
|
97
|
+
"Effort",
|
|
98
|
+
"InputField",
|
|
99
|
+
"Integration",
|
|
100
|
+
"IntegrationList",
|
|
101
|
+
"InternalServerError",
|
|
102
|
+
"InvalidResponseError",
|
|
103
|
+
"Loop",
|
|
104
|
+
"LoopCadence",
|
|
105
|
+
"LoopCreated",
|
|
106
|
+
"LoopDetail",
|
|
107
|
+
"LoopRun",
|
|
108
|
+
"LoopRunNow",
|
|
109
|
+
"NotFoundError",
|
|
110
|
+
"Page",
|
|
111
|
+
"PermissionDeniedError",
|
|
112
|
+
"PollTimeoutError",
|
|
113
|
+
"RateLimitError",
|
|
114
|
+
"Report",
|
|
115
|
+
"RetryResult",
|
|
116
|
+
"Run",
|
|
117
|
+
"RunStatus",
|
|
118
|
+
"RunSummary",
|
|
119
|
+
"ServiceUnavailableError",
|
|
120
|
+
"Skill",
|
|
121
|
+
"SkillContent",
|
|
122
|
+
"SkillList",
|
|
123
|
+
"SkillRecord",
|
|
124
|
+
"SkillSummary",
|
|
125
|
+
"UnprocessableEntityError",
|
|
126
|
+
"Usage",
|
|
127
|
+
"__version__",
|
|
128
|
+
"parse_batch_csv",
|
|
129
|
+
]
|
agentrun/_batch.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Validate batch inputs and parse CSV files into questions plus row data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import IO, Any
|
|
11
|
+
|
|
12
|
+
MAX_INPUTS = 100
|
|
13
|
+
MAX_INPUT_CHARS = 8_000
|
|
14
|
+
MAX_CSV_BYTES = 1_000_000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class BatchCSV:
|
|
19
|
+
"""A CSV source for a batch. One column holds the input for each row.
|
|
20
|
+
|
|
21
|
+
``source`` may be CSV text, UTF-8 bytes, a path, or an open file. The other
|
|
22
|
+
columns are sent as ``row_data`` and come back with each result.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
source: str | bytes | Path | IO[str] | IO[bytes]
|
|
26
|
+
input_column: str = "input"
|
|
27
|
+
|
|
28
|
+
def text(self) -> str:
|
|
29
|
+
raw: str | bytes
|
|
30
|
+
if isinstance(self.source, Path):
|
|
31
|
+
raw = self.source.read_bytes()
|
|
32
|
+
elif isinstance(self.source, (str, bytes)):
|
|
33
|
+
raw = self.source
|
|
34
|
+
else:
|
|
35
|
+
raw = self.source.read()
|
|
36
|
+
if isinstance(raw, bytes):
|
|
37
|
+
if len(raw) > MAX_CSV_BYTES:
|
|
38
|
+
raise ValueError("CSV must be at most 1 MB.")
|
|
39
|
+
return raw.decode("utf-8-sig")
|
|
40
|
+
if len(raw.encode("utf-8")) > MAX_CSV_BYTES:
|
|
41
|
+
raise ValueError("CSV must be at most 1 MB.")
|
|
42
|
+
return raw.lstrip("")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_batch_csv(text: str, input_column: str) -> tuple[list[str], list[dict[str, str]]]:
|
|
46
|
+
"""Return ``(questions, rows)`` from CSV text. Rows that are entirely blank are skipped."""
|
|
47
|
+
reader = csv.reader(io.StringIO(text.lstrip("\ufeff"), newline=""))
|
|
48
|
+
try:
|
|
49
|
+
headers = next(reader)
|
|
50
|
+
except StopIteration:
|
|
51
|
+
raise ValueError("CSV is empty.") from None
|
|
52
|
+
if any(not h.strip() for h in headers) or len(set(headers)) != len(headers):
|
|
53
|
+
raise ValueError("CSV headers must be unique and non-blank.")
|
|
54
|
+
if input_column not in headers:
|
|
55
|
+
raise ValueError(f"CSV has no {input_column!r} column. Headers are: {', '.join(headers)}.")
|
|
56
|
+
rows: list[dict[str, str]] = []
|
|
57
|
+
for values in reader:
|
|
58
|
+
if not any(v.strip() for v in values):
|
|
59
|
+
continue
|
|
60
|
+
if len(values) != len(headers):
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"CSV row {len(rows) + 2} has {len(values)} cells but there are {len(headers)} headers."
|
|
63
|
+
)
|
|
64
|
+
rows.append(dict(zip(headers, values, strict=True)))
|
|
65
|
+
if len(rows) > MAX_INPUTS:
|
|
66
|
+
raise ValueError(f"A batch accepts at most {MAX_INPUTS} inputs.")
|
|
67
|
+
return [row[input_column] for row in rows], rows
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def prepare_inputs(inputs: str | Sequence[str] | BatchCSV) -> tuple[list[str], list[dict[str, Any]] | None]:
|
|
71
|
+
"""Normalise anything ``batch``/``loop`` accepts into ``(questions, rows)`` and enforce limits."""
|
|
72
|
+
rows: list[dict[str, Any]] | None = None
|
|
73
|
+
if isinstance(inputs, BatchCSV):
|
|
74
|
+
questions, rows = parse_batch_csv(inputs.text(), inputs.input_column)
|
|
75
|
+
elif isinstance(inputs, str):
|
|
76
|
+
questions = [inputs]
|
|
77
|
+
else:
|
|
78
|
+
questions = list(inputs)
|
|
79
|
+
if not 1 <= len(questions) <= MAX_INPUTS:
|
|
80
|
+
raise ValueError(f"Provide between 1 and {MAX_INPUTS} inputs.")
|
|
81
|
+
for index, value in enumerate(questions):
|
|
82
|
+
if not isinstance(value, str) or not value.strip(): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
83
|
+
raise ValueError(f"Input {index} is blank.")
|
|
84
|
+
if len(value) > MAX_INPUT_CHARS:
|
|
85
|
+
raise ValueError(f"Input {index} is longer than {MAX_INPUT_CHARS} characters.")
|
|
86
|
+
return questions, rows
|
agentrun/_http.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""HTTP plumbing shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
An ``Op`` describes one API call without performing it. The transports send
|
|
4
|
+
an ``Op`` and decode the response into the record type it names. Nothing here
|
|
5
|
+
retries, follows redirects, or logs the API key.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Generic, TypeVar
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
import httpx2
|
|
18
|
+
from pydantic import BaseModel, ValidationError
|
|
19
|
+
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
from .errors import APIConnectionError, APITimeoutError, InvalidResponseError, error_for_status
|
|
22
|
+
from .models import Artifact
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.grep.ai/api/v2"
|
|
25
|
+
DEFAULT_TIMEOUT = 30.0
|
|
26
|
+
USER_AGENT = f"agentrun-python/{__version__}"
|
|
27
|
+
_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T", bound=BaseModel)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Op(Generic[T]):
|
|
34
|
+
"""One API call: where to send it, what to send, and what comes back."""
|
|
35
|
+
|
|
36
|
+
method: str
|
|
37
|
+
path: str
|
|
38
|
+
model: type[T]
|
|
39
|
+
params: Mapping[str, Any] | None = None
|
|
40
|
+
json: Mapping[str, Any] | None = None
|
|
41
|
+
idempotency_key: str | None = None
|
|
42
|
+
raw: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Config:
|
|
47
|
+
api_key: str
|
|
48
|
+
base_url: str
|
|
49
|
+
timeout: float
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def resolve(cls, api_key: str | None, base_url: str | None, timeout: float | None) -> Config:
|
|
53
|
+
"""Fill missing values from ``AGENTRUN_API_KEY`` and ``AGENTRUN_BASE_URL``, then validate."""
|
|
54
|
+
key = api_key if api_key is not None else os.environ.get("AGENTRUN_API_KEY", "")
|
|
55
|
+
if not key.strip():
|
|
56
|
+
raise ValueError("An API key is required. Pass api_key= or set AGENTRUN_API_KEY.")
|
|
57
|
+
url = base_url if base_url is not None else os.environ.get("AGENTRUN_BASE_URL") or DEFAULT_BASE_URL
|
|
58
|
+
seconds = DEFAULT_TIMEOUT if timeout is None else timeout
|
|
59
|
+
if seconds <= 0:
|
|
60
|
+
raise ValueError("timeout must be a positive number of seconds.")
|
|
61
|
+
return cls(api_key=key, base_url=_validate_base_url(url), timeout=seconds)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _validate_base_url(value: str) -> str:
|
|
65
|
+
parts = urlsplit(value)
|
|
66
|
+
if parts.username or parts.password or parts.query or parts.fragment or not parts.hostname:
|
|
67
|
+
raise ValueError("base_url must be a plain URL with no credentials, query string, or fragment.")
|
|
68
|
+
if parts.scheme != "https" and not (parts.scheme == "http" and parts.hostname in _LOCAL_HOSTS):
|
|
69
|
+
raise ValueError("base_url must use HTTPS. Plain HTTP is only allowed for localhost.")
|
|
70
|
+
return value.rstrip("/")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _clean(values: Mapping[str, Any]) -> dict[str, Any]:
|
|
74
|
+
"""Drop ``None`` so optional fields are omitted instead of sent as null."""
|
|
75
|
+
return {k: v for k, v in values.items() if v is not None}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_kwargs(op: Op[Any], config: Config) -> dict[str, Any]:
|
|
79
|
+
headers = {
|
|
80
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
81
|
+
"Accept": "application/json, */*;q=0.5",
|
|
82
|
+
"User-Agent": USER_AGENT,
|
|
83
|
+
}
|
|
84
|
+
if op.idempotency_key:
|
|
85
|
+
headers["Idempotency-Key"] = op.idempotency_key
|
|
86
|
+
kwargs: dict[str, Any] = {
|
|
87
|
+
"method": op.method,
|
|
88
|
+
"url": op.path,
|
|
89
|
+
"headers": headers,
|
|
90
|
+
"params": _clean(op.params or {}),
|
|
91
|
+
}
|
|
92
|
+
if op.json is not None:
|
|
93
|
+
kwargs["json"] = _clean(op.json)
|
|
94
|
+
return kwargs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def decode(op: Op[T], response: httpx2.Response) -> T:
|
|
98
|
+
"""Turn an HTTP response into ``op.model`` or raise the matching SDK error."""
|
|
99
|
+
request_id = response.headers.get("x-request-id")
|
|
100
|
+
if response.status_code >= 400:
|
|
101
|
+
raise error_for_status(response.status_code, _body(response), request_id)
|
|
102
|
+
if op.raw:
|
|
103
|
+
artifact = Artifact(
|
|
104
|
+
content=response.content,
|
|
105
|
+
content_type=response.headers.get("content-type"),
|
|
106
|
+
filename=_filename(response.headers.get("content-disposition")),
|
|
107
|
+
)
|
|
108
|
+
return artifact # type: ignore[return-value]
|
|
109
|
+
body = _body(response)
|
|
110
|
+
if not isinstance(body, dict):
|
|
111
|
+
raise InvalidResponseError(
|
|
112
|
+
f"Expected a JSON object from {op.method} {op.path} but got "
|
|
113
|
+
f"{response.headers.get('content-type', 'no content type')} (HTTP {response.status_code}). "
|
|
114
|
+
"Check base_url points at the AgentRun API."
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
return op.model.model_validate(body)
|
|
118
|
+
except ValidationError as error:
|
|
119
|
+
raise InvalidResponseError(f"{op.method} {op.path} returned an unexpected shape: {error}") from None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _body(response: httpx2.Response) -> Any:
|
|
123
|
+
try:
|
|
124
|
+
return response.json()
|
|
125
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
126
|
+
return response.text
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _filename(disposition: str | None) -> str | None:
|
|
130
|
+
if not disposition:
|
|
131
|
+
return None
|
|
132
|
+
for part in disposition.split(";"):
|
|
133
|
+
part = part.strip()
|
|
134
|
+
if part.lower().startswith("filename="):
|
|
135
|
+
return part[len("filename=") :].strip('"')
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def wrap_transport_error(error: httpx2.HTTPError) -> APIConnectionError:
|
|
140
|
+
if isinstance(error, httpx2.TimeoutException):
|
|
141
|
+
return APITimeoutError(f"Request timed out: {error.__class__.__name__}")
|
|
142
|
+
return APIConnectionError(f"Could not reach the AgentRun API: {error.__class__.__name__}: {error}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class SyncTransport:
|
|
146
|
+
def __init__(self, config: Config, transport: httpx2.BaseTransport | None = None) -> None:
|
|
147
|
+
self._client = httpx2.Client(
|
|
148
|
+
base_url=config.base_url,
|
|
149
|
+
timeout=config.timeout,
|
|
150
|
+
follow_redirects=False,
|
|
151
|
+
transport=transport,
|
|
152
|
+
)
|
|
153
|
+
self._config = config
|
|
154
|
+
|
|
155
|
+
def send(self, op: Op[T]) -> T:
|
|
156
|
+
try:
|
|
157
|
+
response = self._client.request(**build_kwargs(op, self._config))
|
|
158
|
+
except httpx2.HTTPError as error:
|
|
159
|
+
raise wrap_transport_error(error) from None
|
|
160
|
+
return decode(op, response)
|
|
161
|
+
|
|
162
|
+
def close(self) -> None:
|
|
163
|
+
self._client.close()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class AsyncTransport:
|
|
167
|
+
def __init__(self, config: Config, transport: httpx2.AsyncBaseTransport | None = None) -> None:
|
|
168
|
+
self._client = httpx2.AsyncClient(
|
|
169
|
+
base_url=config.base_url,
|
|
170
|
+
timeout=config.timeout,
|
|
171
|
+
follow_redirects=False,
|
|
172
|
+
transport=transport,
|
|
173
|
+
)
|
|
174
|
+
self._config = config
|
|
175
|
+
|
|
176
|
+
async def send(self, op: Op[T]) -> T:
|
|
177
|
+
try:
|
|
178
|
+
response = await self._client.request(**build_kwargs(op, self._config))
|
|
179
|
+
except httpx2.HTTPError as error:
|
|
180
|
+
raise wrap_transport_error(error) from None
|
|
181
|
+
return decode(op, response)
|
|
182
|
+
|
|
183
|
+
async def aclose(self) -> None:
|
|
184
|
+
await self._client.aclose()
|