hitl-client 0.1.0__tar.gz

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,4 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ build/
4
+ dist/
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: hitl-client
3
+ Version: 0.1.0
4
+ Summary: Zero-dependency Python SDK for HITL Broker: create/wait/consume human-in-the-loop checkpoints (approvals, OTP/short text, phone/app completion, image/QR display, live browser handoff).
5
+ Project-URL: Homepage, https://github.com/aqiu9/HITL-Broker
6
+ Project-URL: Repository, https://github.com/aqiu9/HITL-Broker
7
+ License: MIT
8
+ Keywords: approval,client,hitl,human-in-the-loop,otp,sdk
9
+ Requires-Python: >=3.9
10
+ Provides-Extra: browser
11
+ Requires-Dist: hitl-cdp-browser-provider; extra == 'browser'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # hitl-client
15
+
16
+ Zero-dependency Python SDK for [HITL Broker](https://github.com/aqiu9/HITL-Broker) —
17
+ create, wait for, and consume human-in-the-loop checkpoints from an agent or
18
+ automation script: approvals (`decision`), OTP/short text (`text`),
19
+ phone/app confirmations (`completion`), image or QR display (`image`/`qr`),
20
+ and live browser handoff (`browser`).
21
+
22
+ This is the packaged form of the HITL-Broker repo's own
23
+ `client/hitl_client.py` — same file, published so callers don't need a full
24
+ repo checkout to `import` it. Talks to the Broker over plain
25
+ `urllib` (stdlib only, no runtime dependencies).
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install hitl-client
31
+ ```
32
+
33
+ ## Use
34
+
35
+ ```python
36
+ import os
37
+ from pathlib import Path
38
+ from hitl_client import HitlClient
39
+
40
+ base_url = os.environ["HITL_INTERNAL_URL"] # e.g. http://192.168.1.10:8081
41
+ api_key = Path(os.environ["HITL_API_KEY_FILE"]).read_text(encoding="utf-8").strip()
42
+ client = HitlClient(base_url, api_key)
43
+
44
+ result = client.wait_for_decision(
45
+ "Approve this deploy?",
46
+ description="Applies a schema migration if approved.",
47
+ external_id="run-42:approve-deploy",
48
+ )
49
+ if result.response["decision"] == "approve":
50
+ apply_deploy()
51
+ else:
52
+ abort_deploy()
53
+ client.consume(result.task_id)
54
+ ```
55
+
56
+ `create_*`/`wait_for_*` cover all six task kinds; see the class docstrings
57
+ in `hitl_client/client.py` for the full non-blocking + blocking API, and
58
+ the main [HITL-Broker README](https://github.com/aqiu9/HITL-Broker) for
59
+ task-kind semantics, the state machine, and realistic `expires_in` values
60
+ (especially the OTP two-step timing pattern — trigger the SMS only after a
61
+ human has confirmed they're ready, not before).
62
+
63
+ ## Do you also need browser support?
64
+
65
+ Only if you're using the `browser` task kind (a human takes over a live
66
+ browser session). Everything else — `decision`/`text`/`completion`/
67
+ `image`/`qr` — works with just `pip install hitl-client`, nothing else.
68
+
69
+ For `browser` tasks you need a small bridge process running on **whichever
70
+ machine is actually hosting the browser session being handed off** — not
71
+ necessarily the same machine that installed `hitl-client`. Pick one based on
72
+ what that machine has, they're functionally identical:
73
+
74
+ ```bash
75
+ # That machine runs Python:
76
+ pip install hitl-cdp-browser-provider
77
+ # — or, to also install it here as an extra when you already know you'll need it:
78
+ pip install hitl-client[browser]
79
+
80
+ # That machine runs Node instead:
81
+ npm install -g hitl-cdp-browser-provider
82
+ ```
83
+
84
+ You only need one of the two, not both. See
85
+ [`hitl-cdp-browser-provider`](https://github.com/aqiu9/HITL-Broker/tree/main/packages)
86
+ for setup and the `--token` security flag.
@@ -0,0 +1,73 @@
1
+ # hitl-client
2
+
3
+ Zero-dependency Python SDK for [HITL Broker](https://github.com/aqiu9/HITL-Broker) —
4
+ create, wait for, and consume human-in-the-loop checkpoints from an agent or
5
+ automation script: approvals (`decision`), OTP/short text (`text`),
6
+ phone/app confirmations (`completion`), image or QR display (`image`/`qr`),
7
+ and live browser handoff (`browser`).
8
+
9
+ This is the packaged form of the HITL-Broker repo's own
10
+ `client/hitl_client.py` — same file, published so callers don't need a full
11
+ repo checkout to `import` it. Talks to the Broker over plain
12
+ `urllib` (stdlib only, no runtime dependencies).
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install hitl-client
18
+ ```
19
+
20
+ ## Use
21
+
22
+ ```python
23
+ import os
24
+ from pathlib import Path
25
+ from hitl_client import HitlClient
26
+
27
+ base_url = os.environ["HITL_INTERNAL_URL"] # e.g. http://192.168.1.10:8081
28
+ api_key = Path(os.environ["HITL_API_KEY_FILE"]).read_text(encoding="utf-8").strip()
29
+ client = HitlClient(base_url, api_key)
30
+
31
+ result = client.wait_for_decision(
32
+ "Approve this deploy?",
33
+ description="Applies a schema migration if approved.",
34
+ external_id="run-42:approve-deploy",
35
+ )
36
+ if result.response["decision"] == "approve":
37
+ apply_deploy()
38
+ else:
39
+ abort_deploy()
40
+ client.consume(result.task_id)
41
+ ```
42
+
43
+ `create_*`/`wait_for_*` cover all six task kinds; see the class docstrings
44
+ in `hitl_client/client.py` for the full non-blocking + blocking API, and
45
+ the main [HITL-Broker README](https://github.com/aqiu9/HITL-Broker) for
46
+ task-kind semantics, the state machine, and realistic `expires_in` values
47
+ (especially the OTP two-step timing pattern — trigger the SMS only after a
48
+ human has confirmed they're ready, not before).
49
+
50
+ ## Do you also need browser support?
51
+
52
+ Only if you're using the `browser` task kind (a human takes over a live
53
+ browser session). Everything else — `decision`/`text`/`completion`/
54
+ `image`/`qr` — works with just `pip install hitl-client`, nothing else.
55
+
56
+ For `browser` tasks you need a small bridge process running on **whichever
57
+ machine is actually hosting the browser session being handed off** — not
58
+ necessarily the same machine that installed `hitl-client`. Pick one based on
59
+ what that machine has, they're functionally identical:
60
+
61
+ ```bash
62
+ # That machine runs Python:
63
+ pip install hitl-cdp-browser-provider
64
+ # — or, to also install it here as an extra when you already know you'll need it:
65
+ pip install hitl-client[browser]
66
+
67
+ # That machine runs Node instead:
68
+ npm install -g hitl-cdp-browser-provider
69
+ ```
70
+
71
+ You only need one of the two, not both. See
72
+ [`hitl-cdp-browser-provider`](https://github.com/aqiu9/HITL-Broker/tree/main/packages)
73
+ for setup and the `--token` security flag.
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hitl-client"
7
+ version = "0.1.0"
8
+ description = "Zero-dependency Python SDK for HITL Broker: create/wait/consume human-in-the-loop checkpoints (approvals, OTP/short text, phone/app completion, image/QR display, live browser handoff)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["hitl", "human-in-the-loop", "approval", "otp", "sdk", "client"]
13
+
14
+ [project.optional-dependencies]
15
+ browser = ["hitl-cdp-browser-provider"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/aqiu9/HITL-Broker"
19
+ Repository = "https://github.com/aqiu9/HITL-Broker"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/hitl_client"]
@@ -0,0 +1,24 @@
1
+ from .client import (
2
+ DEFAULT_POLL_POLICY,
3
+ HitlClient,
4
+ HitlError,
5
+ HitlHTTPError,
6
+ HitlTimeoutError,
7
+ HitlUnansweredError,
8
+ PollPolicy,
9
+ SubmittedResponse,
10
+ TaskHandle,
11
+ )
12
+
13
+ __all__ = [
14
+ "HitlClient",
15
+ "HitlError",
16
+ "HitlHTTPError",
17
+ "HitlTimeoutError",
18
+ "HitlUnansweredError",
19
+ "TaskHandle",
20
+ "SubmittedResponse",
21
+ "PollPolicy",
22
+ "DEFAULT_POLL_POLICY",
23
+ ]
24
+ __version__ = "0.1.0"
@@ -0,0 +1,408 @@
1
+ """HITL Broker Python client SDK (design doc section 19, extended by the
2
+ Image/QR/Browser increment doc section 23).
3
+
4
+ Zero external dependencies (stdlib ``urllib`` only) so it can be dropped
5
+ into any CT without adding a package footprint -- consistent with section
6
+ 25's minimal-stack philosophy. This is the packaged/published form (PyPI:
7
+ ``hitl-client``) of the HITL-Broker repo's own ``client/hitl_client.py``;
8
+ either ``pip install hitl-client`` or copy that single file directly onto a
9
+ client CT -- both are supported and stay in sync.
10
+
11
+ Two layers, per section 19.1/19.2:
12
+
13
+ * Non-blocking primitives -- ``create_decision`` / ``create_text`` /
14
+ ``create_completion`` / ``create_image`` / ``create_qr`` /
15
+ ``create_browser`` / ``put_artifact`` / ``get`` / ``consume`` /
16
+ ``resolve`` / ``cancel`` -- for a proper checkpoint/resume automation
17
+ that can shut down and come back.
18
+ * Blocking convenience wrappers -- ``wait_for_decision`` /
19
+ ``wait_for_text`` / ``wait_for_completion`` -- for short-lived scripts
20
+ (typically the OTP flow, section 20) that are fine blocking in place.
21
+ They return a ``SubmittedResponse`` that the caller explicitly consumes
22
+ only after the downstream action accepts it. image/qr/browser tasks have
23
+ no analogous helper; the automation calls ``resolve()`` after confirming
24
+ the outcome by its own means.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import random
31
+ import time
32
+ import urllib.error
33
+ import urllib.request
34
+ from dataclasses import dataclass
35
+ from typing import Any
36
+
37
+
38
+ class HitlError(RuntimeError):
39
+ """Base class for all client-raised errors."""
40
+
41
+
42
+ class HitlHTTPError(HitlError):
43
+ def __init__(self, status_code: int, code: str, message: str):
44
+ super().__init__(f"HTTP {status_code} [{code}]: {message}")
45
+ self.status_code = status_code
46
+ self.code = code
47
+ self.message = message
48
+
49
+
50
+ class HitlTimeoutError(HitlError):
51
+ """The client's own poll budget was exhausted while the task was still
52
+ ``pending`` -- the task may still be answered later; it is NOT
53
+ automatically cancelled (see wait_for_* docstrings)."""
54
+
55
+
56
+ class HitlUnansweredError(HitlError):
57
+ """The task left ``pending`` without a human response (expired or was
58
+ cancelled), as opposed to being genuinely approved/rejected/filled."""
59
+
60
+
61
+ @dataclass
62
+ class TaskHandle:
63
+ task_id: str
64
+ status: str
65
+ review_url: str | None
66
+ expires_at: str
67
+ poll_after: int
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class SubmittedResponse:
72
+ """A human response that remains readable until the caller confirms
73
+ successful downstream use with ``consume(task_id)``."""
74
+
75
+ task_id: str
76
+ kind: str
77
+ response: dict[str, Any]
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class PollPolicy:
82
+ """Section 19.3 tiered polling: fast during the OTP-scale first 30s,
83
+ settling to a slower cadence, with jitter to avoid a thundering herd
84
+ against the broker when many tasks are in flight concurrently."""
85
+
86
+ fast_window_seconds: float = 30.0
87
+ fast_interval_seconds: float = 3.0
88
+ medium_window_seconds: float = 120.0
89
+ medium_interval_seconds: float = 10.0
90
+ slow_interval_seconds: float = 25.0
91
+ jitter_seconds: float = 3.0
92
+
93
+ def interval_at(self, elapsed_seconds: float) -> float:
94
+ if elapsed_seconds < self.fast_window_seconds:
95
+ base = self.fast_interval_seconds
96
+ elif elapsed_seconds < self.fast_window_seconds + self.medium_window_seconds:
97
+ base = self.medium_interval_seconds
98
+ else:
99
+ base = self.slow_interval_seconds
100
+ return max(0.5, base + random.uniform(0, self.jitter_seconds))
101
+
102
+
103
+ DEFAULT_POLL_POLICY = PollPolicy()
104
+
105
+
106
+ class HitlClient:
107
+ def __init__(self, base_url: str, api_key: str, *, timeout: float = 10.0):
108
+ self._base_url = base_url.rstrip("/")
109
+ self._api_key = api_key
110
+ self._timeout = timeout
111
+
112
+ # -- low-level HTTP -----------------------------------------------
113
+
114
+ def _request(
115
+ self,
116
+ method: str,
117
+ path: str,
118
+ *,
119
+ json_body: dict[str, Any] | None = None,
120
+ idempotency_key: str | None = None,
121
+ ) -> dict[str, Any]:
122
+ url = f"{self._base_url}{path}"
123
+ headers = {"Authorization": f"Bearer {self._api_key}"}
124
+ data = None
125
+ if json_body is not None:
126
+ data = json.dumps(json_body).encode("utf-8")
127
+ headers["Content-Type"] = "application/json"
128
+ if idempotency_key:
129
+ headers["Idempotency-Key"] = idempotency_key
130
+
131
+ request = urllib.request.Request(url, data=data, method=method, headers=headers)
132
+ try:
133
+ with urllib.request.urlopen(request, timeout=self._timeout) as response:
134
+ raw = response.read()
135
+ return json.loads(raw) if raw else {}
136
+ except urllib.error.HTTPError as exc:
137
+ raw = exc.read()
138
+ try:
139
+ body = json.loads(raw) if raw else {}
140
+ except json.JSONDecodeError:
141
+ body = {}
142
+ error = body.get("error", {}) if isinstance(body, dict) else {}
143
+ raise HitlHTTPError(
144
+ exc.code, error.get("code", "http_error"), error.get("message", str(exc.reason))
145
+ ) from exc
146
+ except urllib.error.URLError as exc:
147
+ raise HitlError(f"request to {path} failed: {exc.reason}") from exc
148
+
149
+ def _request_bytes(self, method: str, path: str, *, data: bytes, content_type: str) -> dict[str, Any]:
150
+ url = f"{self._base_url}{path}"
151
+ headers = {"Authorization": f"Bearer {self._api_key}", "Content-Type": content_type}
152
+ request = urllib.request.Request(url, data=data, method=method, headers=headers)
153
+ try:
154
+ with urllib.request.urlopen(request, timeout=self._timeout) as response:
155
+ raw = response.read()
156
+ return json.loads(raw) if raw else {}
157
+ except urllib.error.HTTPError as exc:
158
+ raw = exc.read()
159
+ try:
160
+ body = json.loads(raw) if raw else {}
161
+ except json.JSONDecodeError:
162
+ body = {}
163
+ error = body.get("error", {}) if isinstance(body, dict) else {}
164
+ raise HitlHTTPError(
165
+ exc.code, error.get("code", "http_error"), error.get("message", str(exc.reason))
166
+ ) from exc
167
+ except urllib.error.URLError as exc:
168
+ raise HitlError(f"request to {path} failed: {exc.reason}") from exc
169
+
170
+ # -- non-blocking primitives (section 19.1) -----------------------
171
+
172
+ def create_decision(
173
+ self,
174
+ title: str,
175
+ *,
176
+ description: str = "",
177
+ expires_in: int | None = None,
178
+ notify: bool = True,
179
+ external_id: str | None = None,
180
+ ) -> TaskHandle:
181
+ return self._create(
182
+ "decision", title=title, description=description, input_spec=None,
183
+ expires_in=expires_in, notify=notify, external_id=external_id,
184
+ )
185
+
186
+ def create_text(
187
+ self,
188
+ title: str,
189
+ *,
190
+ label: str = "",
191
+ secret: bool = True,
192
+ min_length: int | None = None,
193
+ max_length: int | None = None,
194
+ pattern: str | None = None,
195
+ description: str = "",
196
+ expires_in: int | None = None,
197
+ notify: bool = True,
198
+ external_id: str | None = None,
199
+ ) -> TaskHandle:
200
+ input_spec = {
201
+ "label": label, "secret": secret,
202
+ "min_length": min_length, "max_length": max_length, "pattern": pattern,
203
+ }
204
+ return self._create(
205
+ "text", title=title, description=description, input_spec=input_spec,
206
+ expires_in=expires_in, notify=notify, external_id=external_id,
207
+ )
208
+
209
+ def create_completion(
210
+ self,
211
+ title: str,
212
+ *,
213
+ description: str = "",
214
+ expires_in: int | None = None,
215
+ notify: bool = True,
216
+ external_id: str | None = None,
217
+ ) -> TaskHandle:
218
+ return self._create(
219
+ "completion", title=title, description=description, input_spec=None,
220
+ expires_in=expires_in, notify=notify, external_id=external_id,
221
+ )
222
+
223
+ # -- image/qr/browser (increment doc section 23) -----------------------
224
+
225
+ def create_image(
226
+ self,
227
+ title: str,
228
+ *,
229
+ description: str = "",
230
+ expires_in: int | None = None,
231
+ notify: bool = True,
232
+ external_id: str | None = None,
233
+ ) -> TaskHandle:
234
+ """Create the task, then call put_artifact() with the image bytes
235
+ (increment doc section 4.2's two-step flow: create, then upload)."""
236
+ return self._create(
237
+ "image", title=title, description=description, input_spec=None,
238
+ expires_in=expires_in, notify=notify, external_id=external_id,
239
+ )
240
+
241
+ def create_qr(
242
+ self,
243
+ title: str,
244
+ *,
245
+ description: str = "",
246
+ expires_in: int | None = None,
247
+ notify: bool = True,
248
+ external_id: str | None = None,
249
+ ) -> TaskHandle:
250
+ """Create the task, then call put_artifact() with the QR image.
251
+ Call put_artifact() again (same task_id) whenever the QR refreshes
252
+ -- section 8: no new task, just a new artifact version."""
253
+ return self._create(
254
+ "qr", title=title, description=description, input_spec=None,
255
+ expires_in=expires_in, notify=notify, external_id=external_id,
256
+ )
257
+
258
+ def create_browser(
259
+ self,
260
+ title: str,
261
+ *,
262
+ internal_url: str,
263
+ description: str = "",
264
+ expires_in: int | None = None,
265
+ notify: bool = True,
266
+ external_id: str | None = None,
267
+ ) -> TaskHandle:
268
+ """internal_url must match the calling source's configured
269
+ allowed_browser_host/port (increment doc section 19) or the
270
+ broker rejects the create with a 422."""
271
+ return self._create(
272
+ "browser", title=title, description=description, input_spec=None,
273
+ expires_in=expires_in, notify=notify, external_id=external_id, internal_url=internal_url,
274
+ )
275
+
276
+ def put_artifact(self, task_id: str, data: bytes, *, content_type: str) -> dict[str, Any]:
277
+ """Uploads or replaces a task's image artifact. Replacing
278
+ overwrites in place and bumps `version` -- no history is kept."""
279
+ return self._request_bytes("PUT", f"/v1/tasks/{task_id}/artifact", data=data, content_type=content_type)
280
+
281
+ def resolve(self, task_id: str, *, reason: str = "completed") -> dict[str, Any]:
282
+ """Ends an image/qr/browser task directly (pending -> consumed)
283
+ once the automation has independently confirmed the outcome --
284
+ e.g. the QR login succeeded (section 20). Not valid for
285
+ decision/text/completion tasks; use consume() for those after the
286
+ human has submitted a response."""
287
+ return self._request("POST", f"/v1/tasks/{task_id}/resolve", json_body={"reason": reason})
288
+
289
+ def _create(
290
+ self, kind: str, *, title: str, description: str, input_spec: dict[str, Any] | None,
291
+ expires_in: int | None, notify: bool, external_id: str | None, internal_url: str | None = None,
292
+ ) -> TaskHandle:
293
+ body: dict[str, Any] = {"kind": kind, "title": title, "description": description, "notify": notify}
294
+ if input_spec is not None:
295
+ body["input"] = {k: v for k, v in input_spec.items() if v is not None}
296
+ if internal_url is not None:
297
+ body["internal_url"] = internal_url
298
+ if expires_in is not None:
299
+ body["expires_in"] = expires_in
300
+ data = self._request("POST", "/v1/tasks", json_body=body, idempotency_key=external_id)
301
+ return TaskHandle(
302
+ task_id=data["task_id"],
303
+ status=data["status"],
304
+ review_url=data.get("review_url"),
305
+ expires_at=data["expires_at"],
306
+ poll_after=data.get("poll_after", 15),
307
+ )
308
+
309
+ def get(self, task_id: str) -> dict[str, Any]:
310
+ return self._request("GET", f"/v1/tasks/{task_id}")
311
+
312
+ def consume(self, task_id: str) -> dict[str, Any]:
313
+ return self._request("POST", f"/v1/tasks/{task_id}/consume")
314
+
315
+ def cancel(self, task_id: str) -> dict[str, Any]:
316
+ return self._request("POST", f"/v1/tasks/{task_id}/cancel")
317
+
318
+ # -- blocking convenience helpers (section 19.2/20) -----------------
319
+
320
+ def _poll_until_left_pending(self, task_id: str, *, timeout: float, policy: PollPolicy) -> dict[str, Any]:
321
+ start = time.monotonic()
322
+ while True:
323
+ status = self.get(task_id)
324
+ if status.get("status") != "pending":
325
+ return status
326
+ elapsed = time.monotonic() - start
327
+ if elapsed >= timeout:
328
+ raise HitlTimeoutError(f"task {task_id} still pending after {timeout:.0f}s")
329
+ time.sleep(min(policy.interval_at(elapsed), max(0.0, timeout - elapsed)))
330
+
331
+ def wait_for_decision(
332
+ self,
333
+ title: str,
334
+ *,
335
+ description: str = "",
336
+ expires_in: int = 86400,
337
+ timeout: float | None = None,
338
+ poll_policy: PollPolicy = DEFAULT_POLL_POLICY,
339
+ external_id: str | None = None,
340
+ ) -> SubmittedResponse:
341
+ """Block until a decision is submitted, without consuming it.
342
+
343
+ Inspect ``result.response['decision']`` and call
344
+ ``consume(result.task_id)`` only after applying that decision.
345
+ """
346
+ handle = self.create_decision(title, description=description, expires_in=expires_in, external_id=external_id)
347
+ budget = timeout if timeout is not None else float(expires_in)
348
+ status = self._poll_until_left_pending(handle.task_id, timeout=budget, policy=poll_policy)
349
+ if status.get("status") != "submitted":
350
+ raise HitlUnansweredError(f"task {handle.task_id} ended with status={status.get('status')}")
351
+ return SubmittedResponse(
352
+ task_id=handle.task_id,
353
+ kind="decision",
354
+ response=status.get("response") or {},
355
+ )
356
+
357
+ def wait_for_text(
358
+ self,
359
+ title: str,
360
+ *,
361
+ label: str = "",
362
+ secret: bool = True,
363
+ min_length: int | None = None,
364
+ max_length: int | None = None,
365
+ pattern: str | None = None,
366
+ description: str = "",
367
+ expires_in: int = 300,
368
+ timeout: float | None = None,
369
+ poll_policy: PollPolicy = DEFAULT_POLL_POLICY,
370
+ external_id: str | None = None,
371
+ ) -> SubmittedResponse:
372
+ """Blocks until the human submits text (typically an OTP, section
373
+ 20) or the task ends unanswered."""
374
+ handle = self.create_text(
375
+ title, label=label, secret=secret, min_length=min_length, max_length=max_length,
376
+ pattern=pattern, description=description, expires_in=expires_in, external_id=external_id,
377
+ )
378
+ budget = timeout if timeout is not None else float(expires_in)
379
+ status = self._poll_until_left_pending(handle.task_id, timeout=budget, policy=poll_policy)
380
+ if status.get("status") != "submitted":
381
+ raise HitlUnansweredError(f"task {handle.task_id} ended with status={status.get('status')}")
382
+ return SubmittedResponse(
383
+ task_id=handle.task_id,
384
+ kind="text",
385
+ response=status.get("response") or {},
386
+ )
387
+
388
+ def wait_for_completion(
389
+ self,
390
+ title: str,
391
+ *,
392
+ description: str = "",
393
+ expires_in: int = 86400,
394
+ timeout: float | None = None,
395
+ poll_policy: PollPolicy = DEFAULT_POLL_POLICY,
396
+ external_id: str | None = None,
397
+ ) -> SubmittedResponse:
398
+ """Block until completion is submitted, without consuming it."""
399
+ handle = self.create_completion(title, description=description, expires_in=expires_in, external_id=external_id)
400
+ budget = timeout if timeout is not None else float(expires_in)
401
+ status = self._poll_until_left_pending(handle.task_id, timeout=budget, policy=poll_policy)
402
+ if status.get("status") != "submitted":
403
+ raise HitlUnansweredError(f"task {handle.task_id} ended with status={status.get('status')}")
404
+ return SubmittedResponse(
405
+ task_id=handle.task_id,
406
+ kind="completion",
407
+ response=status.get("response") or {},
408
+ )