hundredflags-sdk 0.4.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.
@@ -0,0 +1,62 @@
1
+ """Compatibility imports; use :mod:`hundredflags_sdk` for new code."""
2
+
3
+ from hundredflags_sdk import (
4
+ ActionDescriptor as ActionDescriptor,
5
+ )
6
+ from hundredflags_sdk import (
7
+ ActionValidationError as ActionValidationError,
8
+ )
9
+ from hundredflags_sdk import (
10
+ APIError as APIError,
11
+ )
12
+ from hundredflags_sdk import (
13
+ AsyncClient as AsyncClient,
14
+ )
15
+ from hundredflags_sdk import (
16
+ AuthenticationError as AuthenticationError,
17
+ )
18
+ from hundredflags_sdk import (
19
+ Client as Client,
20
+ )
21
+ from hundredflags_sdk import (
22
+ ConfigurationError as ConfigurationError,
23
+ )
24
+ from hundredflags_sdk import (
25
+ ConflictError as ConflictError,
26
+ )
27
+ from hundredflags_sdk import (
28
+ InstanceDocumentation as InstanceDocumentation,
29
+ )
30
+ from hundredflags_sdk import (
31
+ LimitExceededError as LimitExceededError,
32
+ )
33
+ from hundredflags_sdk import (
34
+ NotFoundError as NotFoundError,
35
+ )
36
+ from hundredflags_sdk import (
37
+ PermissionDeniedError as PermissionDeniedError,
38
+ )
39
+ from hundredflags_sdk import (
40
+ ProtocolError as ProtocolError,
41
+ )
42
+ from hundredflags_sdk import (
43
+ RuntimeResponse as RuntimeResponse,
44
+ )
45
+ from hundredflags_sdk import (
46
+ SDKError as SDKError,
47
+ )
48
+ from hundredflags_sdk import (
49
+ TaskDocumentation as TaskDocumentation,
50
+ )
51
+ from hundredflags_sdk import (
52
+ TaskSummary as TaskSummary,
53
+ )
54
+ from hundredflags_sdk import (
55
+ TransportError as TransportError,
56
+ )
57
+ from hundredflags_sdk import (
58
+ __all__ as __all__,
59
+ )
60
+ from hundredflags_sdk import (
61
+ __version__ as __version__,
62
+ )
@@ -0,0 +1,23 @@
1
+ """Compatibility imports; use :mod:`hundredflags_sdk.async_client` for new code."""
2
+
3
+ from hundredflags_sdk.async_client import (
4
+ AsyncActions as AsyncActions,
5
+ )
6
+ from hundredflags_sdk.async_client import (
7
+ AsyncClient as AsyncClient,
8
+ )
9
+ from hundredflags_sdk.async_client import (
10
+ AsyncEnvironmentResource as AsyncEnvironmentResource,
11
+ )
12
+ from hundredflags_sdk.async_client import (
13
+ AsyncEnvironments as AsyncEnvironments,
14
+ )
15
+ from hundredflags_sdk.async_client import (
16
+ AsyncEnvironmentTasks as AsyncEnvironmentTasks,
17
+ )
18
+ from hundredflags_sdk.async_client import (
19
+ AsyncTaskResource as AsyncTaskResource,
20
+ )
21
+ from hundredflags_sdk.async_client import (
22
+ AsyncTasks as AsyncTasks,
23
+ )
@@ -0,0 +1,23 @@
1
+ """Compatibility imports; use :mod:`hundredflags_sdk.client` for new code."""
2
+
3
+ from hundredflags_sdk.client import (
4
+ Actions as Actions,
5
+ )
6
+ from hundredflags_sdk.client import (
7
+ Client as Client,
8
+ )
9
+ from hundredflags_sdk.client import (
10
+ EnvironmentResource as EnvironmentResource,
11
+ )
12
+ from hundredflags_sdk.client import (
13
+ Environments as Environments,
14
+ )
15
+ from hundredflags_sdk.client import (
16
+ EnvironmentTasks as EnvironmentTasks,
17
+ )
18
+ from hundredflags_sdk.client import (
19
+ TaskResource as TaskResource,
20
+ )
21
+ from hundredflags_sdk.client import (
22
+ Tasks as Tasks,
23
+ )
@@ -0,0 +1,38 @@
1
+ """Compatibility imports; use :mod:`hundredflags_sdk.errors` for new code."""
2
+
3
+ from hundredflags_sdk.errors import (
4
+ ActionValidationError as ActionValidationError,
5
+ )
6
+ from hundredflags_sdk.errors import (
7
+ APIError as APIError,
8
+ )
9
+ from hundredflags_sdk.errors import (
10
+ AuthenticationError as AuthenticationError,
11
+ )
12
+ from hundredflags_sdk.errors import (
13
+ ConfigurationError as ConfigurationError,
14
+ )
15
+ from hundredflags_sdk.errors import (
16
+ ConflictError as ConflictError,
17
+ )
18
+ from hundredflags_sdk.errors import (
19
+ LimitExceededError as LimitExceededError,
20
+ )
21
+ from hundredflags_sdk.errors import (
22
+ NotFoundError as NotFoundError,
23
+ )
24
+ from hundredflags_sdk.errors import (
25
+ PermissionDeniedError as PermissionDeniedError,
26
+ )
27
+ from hundredflags_sdk.errors import (
28
+ ProtocolError as ProtocolError,
29
+ )
30
+ from hundredflags_sdk.errors import (
31
+ SDKError as SDKError,
32
+ )
33
+ from hundredflags_sdk.errors import (
34
+ TransportError as TransportError,
35
+ )
36
+ from hundredflags_sdk.errors import (
37
+ api_error as api_error,
38
+ )
@@ -0,0 +1,26 @@
1
+ """Compatibility imports; use :mod:`hundredflags_sdk.models` for new code."""
2
+
3
+ from hundredflags_sdk.models import (
4
+ ActionDescriptor as ActionDescriptor,
5
+ )
6
+ from hundredflags_sdk.models import (
7
+ InstanceDocumentation as InstanceDocumentation,
8
+ )
9
+ from hundredflags_sdk.models import (
10
+ InstanceList as InstanceList,
11
+ )
12
+ from hundredflags_sdk.models import (
13
+ JsonObject as JsonObject,
14
+ )
15
+ from hundredflags_sdk.models import (
16
+ PublicModel as PublicModel,
17
+ )
18
+ from hundredflags_sdk.models import (
19
+ RuntimeResponse as RuntimeResponse,
20
+ )
21
+ from hundredflags_sdk.models import (
22
+ TaskDocumentation as TaskDocumentation,
23
+ )
24
+ from hundredflags_sdk.models import (
25
+ TaskSummary as TaskSummary,
26
+ )
File without changes
@@ -0,0 +1,49 @@
1
+ """HundredFlags SDK: documentation and actions for existing agent environments."""
2
+
3
+ from importlib.metadata import version as _package_version
4
+
5
+ from .async_client import AsyncClient
6
+ from .client import Client
7
+ from .errors import (
8
+ ActionValidationError,
9
+ APIError,
10
+ AuthenticationError,
11
+ ConfigurationError,
12
+ ConflictError,
13
+ LimitExceededError,
14
+ NotFoundError,
15
+ PermissionDeniedError,
16
+ ProtocolError,
17
+ SDKError,
18
+ TransportError,
19
+ )
20
+ from .models import (
21
+ ActionDescriptor,
22
+ InstanceDocumentation,
23
+ RuntimeResponse,
24
+ TaskDocumentation,
25
+ TaskSummary,
26
+ )
27
+
28
+ __version__ = _package_version("hundredflags-sdk")
29
+
30
+ __all__ = [
31
+ "APIError",
32
+ "ActionDescriptor",
33
+ "ActionValidationError",
34
+ "AsyncClient",
35
+ "AuthenticationError",
36
+ "Client",
37
+ "ConfigurationError",
38
+ "ConflictError",
39
+ "InstanceDocumentation",
40
+ "LimitExceededError",
41
+ "NotFoundError",
42
+ "PermissionDeniedError",
43
+ "ProtocolError",
44
+ "RuntimeResponse",
45
+ "SDKError",
46
+ "TaskDocumentation",
47
+ "TaskSummary",
48
+ "TransportError",
49
+ ]
@@ -0,0 +1,34 @@
1
+ """Shared metadata properties; resources do not represent separate server sessions."""
2
+
3
+ from .models import InstanceDocumentation, TaskDocumentation, TaskSummary
4
+
5
+
6
+ class EnvironmentHandle:
7
+ def __init__(self, info: InstanceDocumentation) -> None:
8
+ self.info = info
9
+
10
+ @property
11
+ def instance_id(self) -> str:
12
+ return self.info.instance_id
13
+
14
+ @property
15
+ def title(self) -> str:
16
+ return self.info.title
17
+
18
+
19
+ class TaskHandle:
20
+ def __init__(self, info: TaskSummary | TaskDocumentation, instance_id: str) -> None:
21
+ self.info = info
22
+ self._instance_id = instance_id
23
+
24
+ @property
25
+ def task_id(self) -> str:
26
+ return self.info.task_id
27
+
28
+ @property
29
+ def instance_id(self) -> str:
30
+ return self._instance_id
31
+
32
+ @property
33
+ def title(self) -> str:
34
+ return self.info.title
@@ -0,0 +1,135 @@
1
+ """Transport policy and response parsing shared by synchronous and async clients."""
2
+
3
+ import math
4
+ import os
5
+ import re
6
+ from importlib.metadata import version
7
+ from typing import Any
8
+ from urllib.parse import urlsplit
9
+
10
+ import httpx
11
+ from pydantic import ValidationError
12
+
13
+ from .errors import ConfigurationError, ProtocolError, api_error
14
+ from .models import JsonObject, PublicModel
15
+
16
+ DEFAULT_BASE_URL = "https://plgn.hundredflags.ru"
17
+ API_PREFIX = "/api/agent-env/"
18
+ RETRYABLE_STATUS = {429, 502, 503, 504}
19
+
20
+
21
+ def identifier(value: str) -> str:
22
+ if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]*", value):
23
+ raise ConfigurationError("Resource identifiers must contain only letters, digits, _ . : -")
24
+ return value
25
+
26
+
27
+ def positive_duration(value: float, name: str, *, allow_zero: bool = False) -> float:
28
+ if not math.isfinite(value) or value < 0 or (value == 0 and not allow_zero):
29
+ raise ConfigurationError(
30
+ f"{name} must be finite and {'non-negative' if allow_zero else 'positive'}"
31
+ )
32
+ return value
33
+
34
+
35
+ def environment_config(overrides: dict[str, Any]) -> dict[str, Any]:
36
+ options: dict[str, Any] = {
37
+ "token": os.environ.get(
38
+ "HUNDREDFLAGS_TOKEN", os.environ.get("AI_SECURITY_SCHOOL_TOKEN", "")
39
+ ),
40
+ "base_url": os.environ.get(
41
+ "HUNDREDFLAGS_BASE_URL",
42
+ os.environ.get("AI_SECURITY_SCHOOL_BASE_URL") or DEFAULT_BASE_URL,
43
+ ),
44
+ }
45
+ options.update(overrides)
46
+ return options
47
+
48
+
49
+ def client_options(
50
+ token: str, base_url: str, timeout: float, max_retries: int, retry_backoff: float
51
+ ) -> dict[str, Any]:
52
+ if not token or token != token.strip() or any(ord(c) < 33 or ord(c) > 126 for c in token):
53
+ raise ConfigurationError("Set HUNDREDFLAGS_TOKEN to a valid learner token")
54
+ try:
55
+ url = urlsplit(base_url)
56
+ except ValueError as exc:
57
+ raise ConfigurationError("base_url must be a valid server URL") from exc
58
+ if (
59
+ url.scheme not in {"https", "http"}
60
+ or not url.netloc
61
+ or url.username is not None
62
+ or url.password is not None
63
+ or url.query
64
+ or url.fragment
65
+ or url.path.rstrip("/") not in {"", API_PREFIX.rstrip("/")}
66
+ ):
67
+ raise ConfigurationError("base_url must be a server origin or agent-env API base URL")
68
+ if url.scheme == "http" and url.hostname not in {"localhost", "127.0.0.1", "::1", "testserver"}:
69
+ raise ConfigurationError(
70
+ "Remote servers require HTTPS; HTTP is supported only for local use"
71
+ )
72
+ positive_duration(timeout, "timeout")
73
+ positive_duration(retry_backoff, "retry_backoff", allow_zero=True)
74
+ if not isinstance(max_retries, int) or isinstance(max_retries, bool) or max_retries < 0:
75
+ raise ConfigurationError("max_retries must be a non-negative integer")
76
+ return {
77
+ "base_url": f"{url.scheme}://{url.netloc}{API_PREFIX}",
78
+ "headers": {
79
+ "Authorization": f"Bearer {token}",
80
+ "Accept": "application/json",
81
+ "User-Agent": f"hundredflags-sdk/{version('hundredflags-sdk')}",
82
+ },
83
+ "timeout": timeout,
84
+ "follow_redirects": False,
85
+ }
86
+
87
+
88
+ def retry_delay(response: httpx.Response | None, attempt: int, backoff: float) -> float:
89
+ if response is not None:
90
+ try:
91
+ delay = float(response.headers.get("Retry-After", ""))
92
+ if math.isfinite(delay) and delay >= 0:
93
+ return min(delay, 30.0)
94
+ except ValueError:
95
+ pass
96
+ return min(math.ldexp(backoff, min(attempt, 10)), 30.0)
97
+
98
+
99
+ def decode_response(response: httpx.Response) -> JsonObject:
100
+ if not 200 <= response.status_code < 300:
101
+ try:
102
+ body = response.json()
103
+ except ValueError:
104
+ body = {}
105
+ if not isinstance(body, dict):
106
+ body = {}
107
+ error = body.get("error")
108
+ error = error if isinstance(error, dict) else {}
109
+ code = error.get("code")
110
+ message = error.get("message")
111
+ details = error.get("details")
112
+ details = dict(details) if isinstance(details, dict) else {}
113
+ for key in ("usage", "retry_after"):
114
+ if key in body:
115
+ details[key] = body[key]
116
+ raise api_error(
117
+ code if isinstance(code, str) else "http_error",
118
+ message if isinstance(message, str) else f"Server returned HTTP {response.status_code}",
119
+ status_code=response.status_code,
120
+ details=details,
121
+ )
122
+ try:
123
+ value = response.json()
124
+ except ValueError as exc:
125
+ raise ProtocolError("Server returned invalid JSON") from exc
126
+ if not isinstance(value, dict):
127
+ raise ProtocolError("Expected a JSON object response")
128
+ return value
129
+
130
+
131
+ def parse_model[ModelT: PublicModel](model: type[ModelT], value: JsonObject) -> ModelT:
132
+ try:
133
+ return model.model_validate(value)
134
+ except ValidationError as exc:
135
+ raise ProtocolError(f"Server returned an invalid {model.__name__} response") from exc
@@ -0,0 +1,71 @@
1
+ """Local schema validation never resolves or downloads remote references."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from jsonschema import Draft202012Validator
7
+ from jsonschema.exceptions import SchemaError
8
+ from referencing import Registry
9
+
10
+ from .errors import ActionValidationError, ProtocolError
11
+ from .models import JsonObject, TaskDocumentation
12
+
13
+
14
+ def _check_references(value: Any) -> None:
15
+ if isinstance(value, dict):
16
+ for key, child in value.items():
17
+ if key in {"$ref", "$dynamicRef"} and (
18
+ not isinstance(child, str) or not child.startswith("#")
19
+ ):
20
+ raise ProtocolError("Action schemas may only use local fragment references")
21
+ _check_references(child)
22
+ elif isinstance(value, list):
23
+ for child in value:
24
+ _check_references(child)
25
+
26
+
27
+ def json_object(value: JsonObject) -> JsonObject:
28
+ if not isinstance(value, dict):
29
+ raise ActionValidationError("Action payload must be a JSON object")
30
+ try:
31
+ frozen: JsonObject = json.loads(json.dumps(value, allow_nan=False))
32
+ except (TypeError, ValueError, OverflowError, RecursionError) as exc:
33
+ raise ActionValidationError("Action payload must contain only finite JSON values") from exc
34
+ return frozen
35
+
36
+
37
+ def validate_payload(
38
+ schema: JsonObject, payload: JsonObject, *, name: str = "action"
39
+ ) -> JsonObject:
40
+ frozen = json_object(payload)
41
+ _check_references(schema)
42
+ try:
43
+ Draft202012Validator.check_schema(schema)
44
+ validator = Draft202012Validator(schema, registry=Registry())
45
+ violation = next(validator.iter_errors(frozen), None)
46
+ except SchemaError as exc:
47
+ raise ProtocolError("Server returned an invalid action input schema") from exc
48
+ except Exception as exc:
49
+ raise ProtocolError("Action input schema contains an unresolvable reference") from exc
50
+ if violation is not None:
51
+ path = list(violation.absolute_path)
52
+ location = ".".join(str(part) for part in path) or "payload"
53
+ raise ActionValidationError(
54
+ f"{name}: {location} fails {violation.validator} validation", path=path
55
+ )
56
+ return frozen
57
+
58
+
59
+ def action_payload(
60
+ documentation: TaskDocumentation, name: str, arguments: JsonObject
61
+ ) -> JsonObject:
62
+ descriptor = next((action for action in documentation.actions if action.name == name), None)
63
+ if descriptor is None:
64
+ raise ActionValidationError("This action is not documented for the task")
65
+ arguments = json_object(arguments)
66
+ if "action" in arguments:
67
+ raise ActionValidationError("Pass the action name separately, without arguments.action")
68
+ validated = validate_payload(descriptor.input_schema, arguments, name=name)
69
+ return validate_payload(
70
+ documentation.action_payload_schema, {"action": name, **validated}, name=name
71
+ )
@@ -0,0 +1,226 @@
1
+ """Asynchronous client for the platform's shared agent-env runtime."""
2
+
3
+ import asyncio
4
+ from types import TracebackType
5
+ from typing import Any, Self
6
+
7
+ import httpx
8
+
9
+ from ._handles import EnvironmentHandle, TaskHandle
10
+ from ._http import (
11
+ DEFAULT_BASE_URL,
12
+ RETRYABLE_STATUS,
13
+ client_options,
14
+ decode_response,
15
+ environment_config,
16
+ identifier,
17
+ parse_model,
18
+ retry_delay,
19
+ )
20
+ from ._schema import action_payload, json_object, validate_payload
21
+ from .errors import ActionValidationError, ConfigurationError, ProtocolError, TransportError
22
+ from .models import (
23
+ ActionDescriptor,
24
+ InstanceDocumentation,
25
+ InstanceList,
26
+ JsonObject,
27
+ RuntimeResponse,
28
+ TaskDocumentation,
29
+ TaskSummary,
30
+ )
31
+
32
+
33
+ class AsyncClient:
34
+ def __init__(
35
+ self,
36
+ token: str,
37
+ *,
38
+ base_url: str = DEFAULT_BASE_URL,
39
+ timeout: float = 120.0,
40
+ max_retries: int = 2,
41
+ retry_backoff: float = 0.25,
42
+ transport: httpx.AsyncBaseTransport | None = None,
43
+ ) -> None:
44
+ options = client_options(token, base_url, timeout, max_retries, retry_backoff)
45
+ self._http = httpx.AsyncClient(**options, transport=transport)
46
+ self._max_retries = max_retries
47
+ self._retry_backoff = retry_backoff
48
+ self.envs = AsyncEnvironments(self)
49
+ self.tasks = AsyncTasks(self)
50
+
51
+ @classmethod
52
+ def from_env(cls, **overrides: Any) -> Self:
53
+ return cls(**environment_config(overrides))
54
+
55
+ async def __aenter__(self) -> Self:
56
+ return self
57
+
58
+ async def __aexit__(
59
+ self,
60
+ exc_type: type[BaseException] | None,
61
+ exc_value: BaseException | None,
62
+ traceback: TracebackType | None,
63
+ ) -> None:
64
+ await self.close()
65
+
66
+ async def close(self) -> None:
67
+ await self._http.aclose()
68
+
69
+ async def _request(
70
+ self,
71
+ method: str,
72
+ path: str,
73
+ *,
74
+ body: JsonObject | None = None,
75
+ params: dict[str, str] | None = None,
76
+ ) -> JsonObject:
77
+ # The shared runtime does not deduplicate mutations. Never retry a POST.
78
+ retries = self._max_retries if method == "GET" else 0
79
+ for attempt in range(retries + 1):
80
+ response: httpx.Response | None = None
81
+ try:
82
+ response = await self._http.request(method, path, json=body, params=params)
83
+ except httpx.TransportError as exc:
84
+ if attempt == retries:
85
+ raise TransportError(may_have_executed=method != "GET") from exc
86
+ else:
87
+ if response.status_code not in RETRYABLE_STATUS or attempt == retries:
88
+ return decode_response(response)
89
+ await asyncio.sleep(retry_delay(response, attempt, self._retry_backoff))
90
+ raise AssertionError("Unreachable retry state")
91
+
92
+
93
+ class AsyncEnvironments:
94
+ def __init__(self, client: AsyncClient) -> None:
95
+ self._client = client
96
+
97
+ async def get(self, instance_id: str) -> "AsyncEnvironmentResource":
98
+ data = await self._client._request("GET", f"instances/{identifier(instance_id)}")
99
+ info = parse_model(InstanceDocumentation, data)
100
+ if info.instance_id != instance_id:
101
+ raise ProtocolError("Server returned documentation for a different environment")
102
+ return AsyncEnvironmentResource(self._client, info)
103
+
104
+ async def list(self) -> list["AsyncEnvironmentResource"]:
105
+ data = parse_model(InstanceList, await self._client._request("GET", "instances"))
106
+ return [AsyncEnvironmentResource(self._client, info) for info in data.instances]
107
+
108
+
109
+ class AsyncTasks:
110
+ def __init__(self, client: AsyncClient) -> None:
111
+ self._client = client
112
+
113
+ async def get(self, task_id: str) -> "AsyncTaskResource":
114
+ data = await self._client._request("GET", f"tasks/{identifier(task_id)}/documentation")
115
+ info = parse_model(TaskDocumentation, data)
116
+ if info.task_id != task_id:
117
+ raise ProtocolError("Server returned documentation for a different task")
118
+ return AsyncTaskResource(self._client, info, info.instance_id)
119
+
120
+
121
+ class AsyncEnvironmentResource(EnvironmentHandle):
122
+ def __init__(self, client: AsyncClient, info: InstanceDocumentation) -> None:
123
+ super().__init__(info)
124
+ self.tasks = AsyncEnvironmentTasks(client, info)
125
+
126
+
127
+ class AsyncEnvironmentTasks:
128
+ def __init__(self, client: AsyncClient, info: InstanceDocumentation) -> None:
129
+ self._client = client
130
+ self._info = info
131
+
132
+ async def list(self) -> list["AsyncTaskResource"]:
133
+ """Return handles from the environment snapshot; load task schemas on use."""
134
+ return [
135
+ AsyncTaskResource(self._client, task, self._info.instance_id)
136
+ for task in self._info.tasks
137
+ ]
138
+
139
+ async def get(self, task_id: str) -> "AsyncTaskResource":
140
+ if not any(task.task_id == task_id for task in self._info.tasks):
141
+ raise ConfigurationError("The task is not in this environment's documentation")
142
+ task = await self._client.tasks.get(task_id)
143
+ if task.instance_id != self._info.instance_id:
144
+ raise ProtocolError("Server returned documentation for a different environment")
145
+ return task
146
+
147
+
148
+ class AsyncTaskResource(TaskHandle):
149
+ def __init__(
150
+ self, client: AsyncClient, info: TaskSummary | TaskDocumentation, instance_id: str
151
+ ) -> None:
152
+ super().__init__(info, instance_id)
153
+ self._client = client
154
+ self.actions = AsyncActions(self)
155
+
156
+ async def documentation(self) -> TaskDocumentation:
157
+ """Fetch current learner documentation and refresh this resource's metadata."""
158
+ data = await self._client._request("GET", f"tasks/{identifier(self.task_id)}/documentation")
159
+ info = parse_model(TaskDocumentation, data)
160
+ if info.task_id != self.task_id or info.instance_id != self.instance_id:
161
+ raise ProtocolError("Server returned documentation for a different task or environment")
162
+ self.info = info
163
+ return info
164
+
165
+ async def state(self) -> RuntimeResponse:
166
+ return parse_model(
167
+ RuntimeResponse,
168
+ await self._client._request(
169
+ "GET", "state", params={"task_id": identifier(self.task_id)}
170
+ ),
171
+ )
172
+
173
+ async def act(self, payload: JsonObject) -> RuntimeResponse:
174
+ """Send the task's native payload, including chat schemas without an action name."""
175
+ documentation = await self.documentation()
176
+ return await self._act(validate_payload(documentation.action_payload_schema, payload))
177
+
178
+ async def _act(self, payload: JsonObject) -> RuntimeResponse:
179
+ return parse_model(
180
+ RuntimeResponse,
181
+ await self._client._request(
182
+ "POST",
183
+ "action",
184
+ body={"task_id": identifier(self.task_id), "action_payload": payload},
185
+ ),
186
+ )
187
+
188
+ async def grade(self, payload: JsonObject | None = None) -> RuntimeResponse:
189
+ validated = json_object(payload if payload is not None else {})
190
+ if not (await self.documentation()).supports_standalone_grading:
191
+ raise ActionValidationError(
192
+ "Standalone grading is unavailable; inspect the task's documented actions"
193
+ )
194
+ return parse_model(
195
+ RuntimeResponse,
196
+ await self._client._request(
197
+ "POST",
198
+ "grade",
199
+ body={
200
+ "task_id": identifier(self.task_id),
201
+ "payload": validated,
202
+ },
203
+ ),
204
+ )
205
+
206
+ async def reset(self) -> RuntimeResponse:
207
+ """Reset the user's shared environment through the task's existing reset behavior."""
208
+ return parse_model(
209
+ RuntimeResponse,
210
+ await self._client._request(
211
+ "POST", "reset", body={"task_id": identifier(self.task_id)}
212
+ ),
213
+ )
214
+
215
+
216
+ class AsyncActions:
217
+ def __init__(self, task: AsyncTaskResource) -> None:
218
+ self._task = task
219
+
220
+ async def list(self) -> list[ActionDescriptor]:
221
+ return (await self._task.documentation()).actions
222
+
223
+ async def call(self, name: str, arguments: JsonObject) -> RuntimeResponse:
224
+ """Validate documented arguments and insert the native action discriminator."""
225
+ documentation = await self._task.documentation()
226
+ return await self._task._act(action_payload(documentation, name, arguments))
@@ -0,0 +1,221 @@
1
+ """Synchronous client for the platform's shared agent-env runtime."""
2
+
3
+ import time
4
+ from types import TracebackType
5
+ from typing import Any, Self
6
+
7
+ import httpx
8
+
9
+ from ._handles import EnvironmentHandle, TaskHandle
10
+ from ._http import (
11
+ DEFAULT_BASE_URL,
12
+ RETRYABLE_STATUS,
13
+ client_options,
14
+ decode_response,
15
+ environment_config,
16
+ identifier,
17
+ parse_model,
18
+ retry_delay,
19
+ )
20
+ from ._schema import action_payload, json_object, validate_payload
21
+ from .errors import ActionValidationError, ConfigurationError, ProtocolError, TransportError
22
+ from .models import (
23
+ ActionDescriptor,
24
+ InstanceDocumentation,
25
+ InstanceList,
26
+ JsonObject,
27
+ RuntimeResponse,
28
+ TaskDocumentation,
29
+ TaskSummary,
30
+ )
31
+
32
+
33
+ class Client:
34
+ def __init__(
35
+ self,
36
+ token: str,
37
+ *,
38
+ base_url: str = DEFAULT_BASE_URL,
39
+ timeout: float = 120.0,
40
+ max_retries: int = 2,
41
+ retry_backoff: float = 0.25,
42
+ transport: httpx.BaseTransport | None = None,
43
+ ) -> None:
44
+ options = client_options(token, base_url, timeout, max_retries, retry_backoff)
45
+ self._http = httpx.Client(**options, transport=transport)
46
+ self._max_retries = max_retries
47
+ self._retry_backoff = retry_backoff
48
+ self.envs = Environments(self)
49
+ self.tasks = Tasks(self)
50
+
51
+ @classmethod
52
+ def from_env(cls, **overrides: Any) -> Self:
53
+ return cls(**environment_config(overrides))
54
+
55
+ def __enter__(self) -> Self:
56
+ return self
57
+
58
+ def __exit__(
59
+ self,
60
+ exc_type: type[BaseException] | None,
61
+ exc_value: BaseException | None,
62
+ traceback: TracebackType | None,
63
+ ) -> None:
64
+ self.close()
65
+
66
+ def close(self) -> None:
67
+ self._http.close()
68
+
69
+ def _request(
70
+ self,
71
+ method: str,
72
+ path: str,
73
+ *,
74
+ body: JsonObject | None = None,
75
+ params: dict[str, str] | None = None,
76
+ ) -> JsonObject:
77
+ # The shared runtime does not deduplicate mutations. Never retry a POST.
78
+ retries = self._max_retries if method == "GET" else 0
79
+ for attempt in range(retries + 1):
80
+ response: httpx.Response | None = None
81
+ try:
82
+ response = self._http.request(method, path, json=body, params=params)
83
+ except httpx.TransportError as exc:
84
+ if attempt == retries:
85
+ raise TransportError(may_have_executed=method != "GET") from exc
86
+ else:
87
+ if response.status_code not in RETRYABLE_STATUS or attempt == retries:
88
+ return decode_response(response)
89
+ time.sleep(retry_delay(response, attempt, self._retry_backoff))
90
+ raise AssertionError("Unreachable retry state")
91
+
92
+
93
+ class Environments:
94
+ def __init__(self, client: Client) -> None:
95
+ self._client = client
96
+
97
+ def get(self, instance_id: str) -> "EnvironmentResource":
98
+ data = self._client._request("GET", f"instances/{identifier(instance_id)}")
99
+ info = parse_model(InstanceDocumentation, data)
100
+ if info.instance_id != instance_id:
101
+ raise ProtocolError("Server returned documentation for a different environment")
102
+ return EnvironmentResource(self._client, info)
103
+
104
+ def list(self) -> list["EnvironmentResource"]:
105
+ data = parse_model(InstanceList, self._client._request("GET", "instances"))
106
+ return [EnvironmentResource(self._client, info) for info in data.instances]
107
+
108
+
109
+ class Tasks:
110
+ def __init__(self, client: Client) -> None:
111
+ self._client = client
112
+
113
+ def get(self, task_id: str) -> "TaskResource":
114
+ data = self._client._request("GET", f"tasks/{identifier(task_id)}/documentation")
115
+ info = parse_model(TaskDocumentation, data)
116
+ if info.task_id != task_id:
117
+ raise ProtocolError("Server returned documentation for a different task")
118
+ return TaskResource(self._client, info, info.instance_id)
119
+
120
+
121
+ class EnvironmentResource(EnvironmentHandle):
122
+ def __init__(self, client: Client, info: InstanceDocumentation) -> None:
123
+ super().__init__(info)
124
+ self.tasks = EnvironmentTasks(client, info)
125
+
126
+
127
+ class EnvironmentTasks:
128
+ def __init__(self, client: Client, info: InstanceDocumentation) -> None:
129
+ self._client = client
130
+ self._info = info
131
+
132
+ def list(self) -> list["TaskResource"]:
133
+ """Return handles from the environment snapshot; load task schemas on use."""
134
+ return [
135
+ TaskResource(self._client, task, self._info.instance_id) for task in self._info.tasks
136
+ ]
137
+
138
+ def get(self, task_id: str) -> "TaskResource":
139
+ if not any(task.task_id == task_id for task in self._info.tasks):
140
+ raise ConfigurationError("The task is not in this environment's documentation")
141
+ task = self._client.tasks.get(task_id)
142
+ if task.instance_id != self._info.instance_id:
143
+ raise ProtocolError("Server returned documentation for a different environment")
144
+ return task
145
+
146
+
147
+ class TaskResource(TaskHandle):
148
+ def __init__(
149
+ self, client: Client, info: TaskSummary | TaskDocumentation, instance_id: str
150
+ ) -> None:
151
+ super().__init__(info, instance_id)
152
+ self._client = client
153
+ self.actions = Actions(self)
154
+
155
+ def documentation(self) -> TaskDocumentation:
156
+ """Fetch current learner documentation and refresh this resource's metadata."""
157
+ data = self._client._request("GET", f"tasks/{identifier(self.task_id)}/documentation")
158
+ info = parse_model(TaskDocumentation, data)
159
+ if info.task_id != self.task_id or info.instance_id != self.instance_id:
160
+ raise ProtocolError("Server returned documentation for a different task or environment")
161
+ self.info = info
162
+ return info
163
+
164
+ def state(self) -> RuntimeResponse:
165
+ return parse_model(
166
+ RuntimeResponse,
167
+ self._client._request("GET", "state", params={"task_id": identifier(self.task_id)}),
168
+ )
169
+
170
+ def act(self, payload: JsonObject) -> RuntimeResponse:
171
+ """Send the task's native payload, including chat schemas without an action name."""
172
+ documentation = self.documentation()
173
+ return self._act(validate_payload(documentation.action_payload_schema, payload))
174
+
175
+ def _act(self, payload: JsonObject) -> RuntimeResponse:
176
+ return parse_model(
177
+ RuntimeResponse,
178
+ self._client._request(
179
+ "POST",
180
+ "action",
181
+ body={"task_id": identifier(self.task_id), "action_payload": payload},
182
+ ),
183
+ )
184
+
185
+ def grade(self, payload: JsonObject | None = None) -> RuntimeResponse:
186
+ validated = json_object(payload if payload is not None else {})
187
+ if not self.documentation().supports_standalone_grading:
188
+ raise ActionValidationError(
189
+ "Standalone grading is unavailable; inspect the task's documented actions"
190
+ )
191
+ return parse_model(
192
+ RuntimeResponse,
193
+ self._client._request(
194
+ "POST",
195
+ "grade",
196
+ body={
197
+ "task_id": identifier(self.task_id),
198
+ "payload": validated,
199
+ },
200
+ ),
201
+ )
202
+
203
+ def reset(self) -> RuntimeResponse:
204
+ """Reset the user's shared environment through the task's existing reset behavior."""
205
+ return parse_model(
206
+ RuntimeResponse,
207
+ self._client._request("POST", "reset", body={"task_id": identifier(self.task_id)}),
208
+ )
209
+
210
+
211
+ class Actions:
212
+ def __init__(self, task: TaskResource) -> None:
213
+ self._task = task
214
+
215
+ def list(self) -> list[ActionDescriptor]:
216
+ return self._task.documentation().actions
217
+
218
+ def call(self, name: str, arguments: JsonObject) -> RuntimeResponse:
219
+ """Validate documented arguments and insert the native action discriminator."""
220
+ documentation = self._task.documentation()
221
+ return self._task._act(action_payload(documentation, name, arguments))
@@ -0,0 +1,89 @@
1
+ """SDK failures omit authentication headers and locally supplied payloads."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class SDKError(Exception):
7
+ """Base class for SDK failures."""
8
+
9
+
10
+ class ConfigurationError(SDKError, ValueError):
11
+ pass
12
+
13
+
14
+ class ProtocolError(SDKError):
15
+ """The server returned an invalid response or unsupported JSON schema."""
16
+
17
+
18
+ class ActionValidationError(SDKError, ValueError):
19
+ """Arguments do not satisfy the task's documented action schema."""
20
+
21
+ def __init__(self, message: str, *, path: list[str | int] | None = None) -> None:
22
+ super().__init__(message)
23
+ self.path = path or []
24
+
25
+
26
+ class APIError(SDKError):
27
+ def __init__(
28
+ self,
29
+ code: str,
30
+ message: str,
31
+ *,
32
+ status_code: int,
33
+ details: dict[str, Any] | None = None,
34
+ ) -> None:
35
+ super().__init__(f"{code}: {message}")
36
+ self.code = code
37
+ self.message = message
38
+ self.status_code = status_code
39
+ self.details = details or {}
40
+ usage = self.details.get("usage")
41
+ self.usage: dict[str, Any] | None = usage if isinstance(usage, dict) else None
42
+
43
+
44
+ class AuthenticationError(APIError):
45
+ pass
46
+
47
+
48
+ class PermissionDeniedError(APIError):
49
+ pass
50
+
51
+
52
+ class NotFoundError(APIError):
53
+ pass
54
+
55
+
56
+ class ConflictError(APIError):
57
+ pass
58
+
59
+
60
+ class LimitExceededError(APIError):
61
+ pass
62
+
63
+
64
+ class TransportError(SDKError):
65
+ """No definitive response was received; a mutation may already have executed."""
66
+
67
+ def __init__(self, *, may_have_executed: bool) -> None:
68
+ message = "Unable to obtain a definitive server response."
69
+ if may_have_executed:
70
+ message += " The action may have executed; inspect task.state() before retrying."
71
+ super().__init__(message)
72
+ self.may_have_executed = may_have_executed
73
+
74
+
75
+ def api_error(
76
+ code: str,
77
+ message: str,
78
+ *,
79
+ status_code: int,
80
+ details: dict[str, Any] | None = None,
81
+ ) -> APIError:
82
+ cls: type[APIError] = {
83
+ 401: AuthenticationError,
84
+ 403: PermissionDeniedError,
85
+ 404: NotFoundError,
86
+ 409: ConflictError,
87
+ 429: LimitExceededError,
88
+ }.get(status_code, APIError)
89
+ return cls(code, message, status_code=status_code, details=details)
@@ -0,0 +1,64 @@
1
+ """Documentation and responses from the shared agent-env API."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ JsonObject = dict[str, Any]
8
+
9
+
10
+ class PublicModel(BaseModel):
11
+ """Preserve additive server fields as data, never executable behavior."""
12
+
13
+ model_config = ConfigDict(extra="allow")
14
+
15
+
16
+ class ActionDescriptor(PublicModel):
17
+ name: str
18
+ description: str
19
+ input_schema: JsonObject
20
+ examples: list[JsonObject] = Field(default_factory=list)
21
+
22
+
23
+ class TaskSummary(PublicModel):
24
+ task_id: str
25
+ ctf_ref: str
26
+ title: str
27
+
28
+
29
+ class InstanceDocumentation(PublicModel):
30
+ instance_id: str
31
+ agent_env_ref: str
32
+ title: str
33
+ description: str
34
+ tasks: list[TaskSummary]
35
+
36
+
37
+ class InstanceList(PublicModel):
38
+ instances: list[InstanceDocumentation]
39
+
40
+
41
+ class TaskDocumentation(TaskSummary):
42
+ instance_id: str
43
+ agent_env_ref: str
44
+ description: str
45
+ instructions: str
46
+ action_payload_schema: JsonObject
47
+ action_payload_examples: list[JsonObject] = Field(default_factory=list)
48
+ actions: list[ActionDescriptor] = Field(default_factory=list)
49
+ supports_grading: bool
50
+ supports_standalone_grading: bool
51
+
52
+
53
+ class RuntimeResponse(PublicModel):
54
+ status: str
55
+ agent_env_ref: str | None = None
56
+ ctf_ref: str | None = None
57
+ state: JsonObject | None = None
58
+ response: JsonObject | None = None
59
+ completed: bool | None = None
60
+ grader_passed: bool | None = None
61
+ grader_result: JsonObject | None = None
62
+ missing_prerequisites: list[JsonObject] | None = None
63
+ usage: JsonObject | None = None
64
+ message: str | None = None
File without changes
@@ -0,0 +1,240 @@
1
+ Metadata-Version: 2.3
2
+ Name: hundredflags-sdk
3
+ Version: 0.4.0
4
+ Summary: Python client for HundredFlags agent environment documentation and actions
5
+ Author: germankochnev
6
+ Author-email: germankochnev <kochgerm@gmail.com>
7
+ Requires-Dist: httpx>=0.28,<1
8
+ Requires-Dist: pydantic>=2.10,<3
9
+ Requires-Dist: jsonschema>=4.23,<5
10
+ Requires-Dist: referencing>=0.35,<1
11
+ Requires-Python: >=3.12
12
+ Project-URL: Homepage, https://github.com/ai-security-lab-itmo/hundredflags-sdk
13
+ Project-URL: Documentation, https://github.com/ai-security-lab-itmo/hundredflags-sdk#readme
14
+ Project-URL: Issues, https://github.com/ai-security-lab-itmo/hundredflags-sdk/issues
15
+ Description-Content-Type: text/markdown
16
+
17
+ # HundredFlags SDK
18
+
19
+ Python-клиент для работы с агентными средами полигона HundredFlags.
20
+ SDK получает документацию конкретной задачи и вызывает тот же `agent-env` API,
21
+ которым пользуется браузер: состояние, действия, проверку и сброс.
22
+
23
+ ## Установка
24
+
25
+ Требуется Python 3.12 или новее.
26
+
27
+ ```sh
28
+ python -m pip install hundredflags-sdk
29
+ ```
30
+
31
+ Для воспроизводимой установки этой версии:
32
+
33
+ ```sh
34
+ python -m pip install hundredflags-sdk==0.4.0
35
+ ```
36
+
37
+ Версия 0.4.0 использует контракт платформы `2026-09-runtime-1` и работает с
38
+ существующими экземплярами `agent-env` и их задачами. Обновляйте SDK вместе с
39
+ платформой; отдельные лаборатории или прогоны создавать не нужно.
40
+
41
+ ## Переход с ai-security-school-sdk
42
+
43
+ Начиная с 0.4.0 пакет называется `hundredflags-sdk`, а основной импорт —
44
+ `hundredflags_sdk`. API задач и контракт `2026-09-runtime-1` сохранены.
45
+
46
+ Для перехода из существующего окружения:
47
+
48
+ ```sh
49
+ python -m pip uninstall ai-security-school-sdk
50
+ python -m pip install --upgrade hundredflags-sdk
51
+ ```
52
+
53
+ Удалите старую зависимость из `pyproject.toml` или `requirements.txt`, заменив
54
+ её на `hundredflags-sdk`. Не устанавливайте оба дистрибутива одновременно:
55
+ они содержат общий совместимый модуль `ai_security_school_sdk`.
56
+ Старые импорты клиентов, моделей и исключений продолжают работать через этот
57
+ модуль; реализация у него общая с `hundredflags_sdk`.
58
+
59
+ `AI_SECURITY_SCHOOL_TOKEN` и `AI_SECURITY_SCHOOL_BASE_URL` остаются совместимыми
60
+ именами переменных окружения. `HUNDREDFLAGS_TOKEN` и `HUNDREDFLAGS_BASE_URL`
61
+ имеют приоритет, когда заданы; аргументы `from_env(...)` имеют приоритет над ними.
62
+
63
+ ## Подключение и документация
64
+
65
+ На странице операции откройте инструкцию подключения. Создайте временный API-ключ
66
+ в личном кабинете платформы и передайте его через переменную окружения. Ключ
67
+ принадлежит вашему аккаунту и работает со всеми доступными вам задачами;
68
+ конкретную задачу выбирайте по её `task_id` в SDK.
69
+ `HUNDREDFLAGS_BASE_URL` можно задать для другого развёртывания; по умолчанию
70
+ используется `https://plgn.hundredflags.ru`.
71
+
72
+ ```sh
73
+ export HUNDREDFLAGS_TOKEN="YOUR_TOKEN"
74
+ ```
75
+
76
+ ```python
77
+ from hundredflags_sdk import Client
78
+
79
+ with Client.from_env() as client:
80
+ for env in client.envs.list():
81
+ print(env.instance_id, env.title)
82
+ for task in env.tasks.list():
83
+ print(task.task_id, task.title)
84
+
85
+ task = client.tasks.get("YOUR_TASK_ID")
86
+ docs = task.documentation()
87
+ print(docs.instructions)
88
+ print(docs.action_payload_schema)
89
+ print(docs.action_payload_examples)
90
+ for action in docs.actions:
91
+ print(action.name, action.description, action.input_schema, action.examples)
92
+ ```
93
+
94
+ `client.envs.get(instance_id)` возвращает одну среду. `env.tasks.list()` возвращает
95
+ задачи из полученного списка; `env.tasks.get(task_id)` загружает документацию
96
+ выбранной задачи. Среда соответствует `agent-env-instance`, задача —
97
+ `ctf-instance`. Документация описывает доступные студенту точки входа, а не
98
+ внутренние инструменты агента. Набор действий и схемы приходят с сервера, поэтому
99
+ новая задача не требует новой версии SDK.
100
+
101
+ ## Выполнение действий
102
+
103
+ Для действия с именем используйте `task.actions.call(name, arguments)`.
104
+ Имя и аргументы выбираются из документации конкретной задачи:
105
+
106
+ ```python
107
+ with Client.from_env() as client:
108
+ task = client.tasks.get("YOUR_TASK_ID")
109
+ print(task.actions.list())
110
+
111
+ # Используйте это имя только если оно есть в документации выбранной задачи.
112
+ result = task.actions.call("send_message", {"message": "Проверь новый документ"})
113
+ print(result.status, result.response, result.state)
114
+ print(task.state().model_dump())
115
+ ```
116
+
117
+ Метод вставляет поле `action` в тело запроса. В `arguments` передаются остальные
118
+ поля; `input_schema` и `examples` описанного действия не содержат `action`.
119
+ SDK проверяет аргументы и полное тело по JSON Schema перед отправкой. Сервер
120
+ применяет проверки существующего обработчика действия, а также контролирует
121
+ доступ, пререквизиты и бюджет пользователя.
122
+
123
+ `task.act(payload)` принимает полное нативное тело действия. Так поддерживаются
124
+ и существующие среды, у которых нет именованных действий, например чат:
125
+
126
+ ```python
127
+ with Client.from_env() as client:
128
+ task = client.tasks.get("YOUR_CHAT_TASK_ID")
129
+ docs = task.documentation()
130
+ print(docs.action_payload_schema, docs.action_payload_examples)
131
+ result = task.act({"message": "Привет"})
132
+ print(result.model_dump())
133
+ ```
134
+
135
+ Пустой `docs.actions` не означает отсутствие возможностей: используйте полную
136
+ схему `action_payload_schema`. SDK не угадывает имена действий по коду runtime.
137
+ Документация не исполняется как Python-код, внешние ссылки JSON Schema не
138
+ загружаются. Внешние поверхности сценария, например MCP-сервис или реестр
139
+ зависимостей, используются через их собственные интерфейсы.
140
+
141
+ ## Состояние, проверка и сброс
142
+
143
+ ```python
144
+ with Client.from_env() as client:
145
+ task = client.tasks.get("YOUR_TASK_ID")
146
+ current = task.state()
147
+ print(current.status, current.missing_prerequisites)
148
+
149
+ if task.documentation().supports_standalone_grading:
150
+ verdict = task.grade()
151
+ print(verdict.grader_passed, verdict.grader_result, verdict.completed)
152
+
153
+ # При необходимости передайте task.grade({...}) полезную нагрузку проверки.
154
+ # Явный сброс через существующее поведение среды:
155
+ # task.reset()
156
+ ```
157
+
158
+ `supports_grading` означает наличие оценивания вообще; `supports_standalone_grading`
159
+ разрешает отдельный вызов `grade()`. Некоторые задания оценивают ответ внутри
160
+ своих действий, например `submit_card` или `submit_finding`.
161
+
162
+ У одного пользователя задачи одной среды разделяют состояние с браузером и
163
+ другими скриптами. Получение нового handle или создание второго клиента не
164
+ создаёт отдельную попытку. Сброс затрагивает общее состояние среды; сохранение
165
+ зачётов и пререквизитов определяется её существующим поведением. Локальный
166
+ контекстный менеджер закрывает только HTTP-соединения.
167
+
168
+ Алгоритм атаки работает в вашем Python-процессе. Вызовы возвращают обычные ответы
169
+ runtime без фоновых заданий SDK, checkpoint, fork или воспроизведения сценария.
170
+ Вызовы одной среды выполняйте последовательно: параллельные кандидаты будут
171
+ менять одно состояние. Async-клиент удобен для неблокирующего ожидания и работы
172
+ с разными независимыми средами.
173
+
174
+ ## Async
175
+
176
+ ```python
177
+ import asyncio
178
+ from hundredflags_sdk import AsyncClient
179
+
180
+
181
+ async def main():
182
+ async with AsyncClient.from_env() as client:
183
+ task = await client.tasks.get("YOUR_TASK_ID")
184
+ docs = await task.documentation()
185
+ print(docs.instructions)
186
+ result = await task.act({"message": "Привет"}) # Если разрешено схемой.
187
+ print(result.response)
188
+ print((await task.state()).state)
189
+
190
+
191
+ asyncio.run(main())
192
+ ```
193
+
194
+ Методы async-ресурсов, включая `env.tasks.list()`, вызываются через `await`.
195
+ Конструкторы, `from_env()`, поля `.info`, `.task_id`, `.instance_id` и `.title`
196
+ синхронные. `task.documentation()` обновляет снимок `.info`; `task.state()`
197
+ получает текущее серверное состояние. Дополнительные поля ответов сохраняются
198
+ в моделях и доступны через `model_dump()`.
199
+
200
+ Примеры: [документация и вызов](https://github.com/ai-security-lab-itmo/hundredflags-sdk/blob/v0.4.0/examples/first_experiment.py),
201
+ [последовательный поиск кандидатов](https://github.com/ai-security-lab-itmo/hundredflags-sdk/blob/v0.4.0/examples/async_search.py),
202
+ [связанные задачи одной среды](https://github.com/ai-security-lab-itmo/hundredflags-sdk/blob/v0.4.0/examples/multistage.py).
203
+
204
+ ## Ошибки и сетевые повторы
205
+
206
+ - `APIError` содержит `code`, `message`, `status_code`, `details` и `usage`.
207
+ Для HTTP 401/403/404/409/429 используются `AuthenticationError`,
208
+ `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `LimitExceededError`.
209
+ - Успешный HTTP-ответ с `status="locked"` остаётся `RuntimeResponse`:
210
+ проверьте `status` и `missing_prerequisites` перед дальнейшими действиями.
211
+ - `ActionValidationError` означает локальное несоответствие схеме,
212
+ `ProtocolError` — некорректный ответ или неподдерживаемую ссылку в схеме.
213
+ - Автоматические повторы допускаются только для GET: при сетевых ошибках и
214
+ HTTP 429/502/503/504. Параметры клиента: `timeout=120`, `max_retries=2`,
215
+ `retry_backoff=0.25`; `max_retries=0` отключает повторы.
216
+ - Действия, проверка и сброс **никогда не повторяются автоматически**.
217
+ При сетевом сбое `TransportError.may_have_executed` показывает, что изменение
218
+ могло уже выполниться. Сначала изучите `task.state()` и только затем решайте,
219
+ нужен ли повтор. Таймаут или отмена async-корутины не доказывают, что сервер
220
+ остановил исполнение.
221
+ - Для удалённого сервера требуется HTTPS. HTTP доступен для локальной разработки;
222
+ перенаправления HTTP не выполняются, чтобы не передавать токен другому адресу.
223
+
224
+ ## Разработка
225
+
226
+ ```sh
227
+ uv sync --python 3.12
228
+ uv run pytest
229
+ uv run ruff check .
230
+ uv run mypy src
231
+ uv build
232
+ ```
233
+
234
+ Пакет не импортирует backend платформы. Sync/async тестируются через HTTPX
235
+ MockTransport против одного контракта `/api/agent-env`.
236
+ Публикация описана в [PUBLISHING.md](https://github.com/ai-security-lab-itmo/hundredflags-sdk/blob/main/PUBLISHING.md).
237
+
238
+ ## Runtime contract
239
+
240
+ Version 0.3 targets the coordinated `2026-09-runtime-1` platform release. HTTP failures use `{ "error": { "code", "message", "details" }, "usage", "retry_after" }`. Task prerequisite and completion metadata refer to explicit task IDs; shared environment state does not imply shared task credit. Upgrade the platform, course clients, and SDK together.
@@ -0,0 +1,18 @@
1
+ ai_security_school_sdk/__init__.py,sha256=I4v9sUJ6uj6DH9vTe1CMuFZqmmoKeUq9dSKgCm3xwWQ,1494
2
+ ai_security_school_sdk/async_client.py,sha256=S0rPROmQ53bDX7EN7S7j9b9-jZvGJnugxJe-kFeDFvg,701
3
+ ai_security_school_sdk/client.py,sha256=xOUJfI-1xOoDbHFxPDrJKhTPGOOkYhW4chKbHpW9EGE,583
4
+ ai_security_school_sdk/errors.py,sha256=vmyiBLUAkJvgnt4WSa6HYrR9JYhMNBtP3mkhDu7goeA,1029
5
+ ai_security_school_sdk/models.py,sha256=EVocNE-PevYveWyY9eMlEMqxk8lz0os8WKjMU1vETuU,705
6
+ ai_security_school_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ hundredflags_sdk/__init__.py,sha256=LbDnXMXXubXQgIlnzkaJ6unSX1taEHE86gnpxFjBWcM,1073
8
+ hundredflags_sdk/_handles.py,sha256=jaN9RjQ6otSoLkDEJ6s2jCR2xF0gTb0hfePmWUV4SCU,843
9
+ hundredflags_sdk/_http.py,sha256=E5R9bgAo85zQli04cz_HOeQKANanr8ciWO7yRfMC_dU,5039
10
+ hundredflags_sdk/_schema.py,sha256=HvHidhn1wD5XTvT8oe-J1B2i5QWzaNNOm04Olrrhy9g,2855
11
+ hundredflags_sdk/async_client.py,sha256=3wkXTheSnyCSCeQrJp-m403jTOyZhxCk3YjKmJlqw2U,8459
12
+ hundredflags_sdk/client.py,sha256=yeY-05hrJ69Zqrzeyg8gIlUWzbyUox5paRYAa7EknFw,8017
13
+ hundredflags_sdk/errors.py,sha256=3mywC8Fkw0b7xfEXgNKw8iFN-MylXHj0KSXEl369hMA,2239
14
+ hundredflags_sdk/models.py,sha256=2uYomyHl_VnvWIsX5_6OLNVN1QBK7Myvpz9xTCRHJ2A,1613
15
+ hundredflags_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ hundredflags_sdk-0.4.0.dist-info/WHEEL,sha256=cmC5s21ojypbVslldL7IJq3hjZH-tINy4rziKePFsG0,81
17
+ hundredflags_sdk-0.4.0.dist-info/METADATA,sha256=P70NGbyLKiS81Xmje_AiRv7TLy3rGHboV-UCL9Vr798,13974
18
+ hundredflags_sdk-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.23
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any