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 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()