diff --git a/python/README.md b/python/README.md index 236a548..90db4df 100644 --- a/python/README.md +++ b/python/README.md @@ -290,7 +290,7 @@ only. | `agent.tasks.append(task_id, *, agent_id, message, metadata=None)` | `AppendResult` | POST `/v1/chat/tasks/{id}/messages`; `status='running'`; raises `TaskBusy` if prior turn is still running | | `agent.tasks.get(task_id)` | `TaskInfo` | GET `/v1/chat/tasks/{id}`; latest-turn `input`/`output` | | `agent.tasks.steps(task_id)` | `list[Step]` | GET `/v1/chat/tasks/{id}/steps`; full timeline | -| `agent.tasks.wait(task_id, *, timeout=120, poll_interval=1.0)` | `TaskInfo` | poll `get()` until terminal (`COMPLETED` or `FAILED`); raises `TaskTimeout` on deadline | +| `agent.tasks.wait(task_id, *, timeout=120, poll_interval=1.0)` | `TaskInfo` | poll `get()` until `COMPLETED`/`FAILED` or `WAITING_FOR_USER`; raises `TaskTimeout` on deadline | | `agent.tasks.run(*, agent_id, message, timeout=120, poll_interval=1.0, metadata=None)` | `RunResult` | `create` + `wait` + `steps` | | `agent.close()` / `with ... as agent` | — | release the connection pool | @@ -343,6 +343,17 @@ The minted runtime key is an ordinary agent key — drive it with - `PAUSED` — agent paused waiting for external action (e.g. another caller appending); **not** terminal — `wait()` keeps polling until the deadline so you observe the resume transition +- `WAITING_FOR_USER` — the agent asked for input and is blocked on **your** + next turn; **not** terminal, but `wait()`/`run()` return here (a passive + poller would never advance it). Send the reply with `append()`, then + `wait()` again: + ```python + result = agent.tasks.run(agent_id=agent_id, message="Book me a flight") + if result.status is TaskStatus.WAITING_FOR_USER: + task_id = result.info.task_id + agent.tasks.append(task_id, agent_id=agent_id, message="To Tokyo, next Friday") + info = agent.tasks.wait(task_id) + ``` - `COMPLETED`, `FAILED` — terminal; `wait()` returns ## Configuration diff --git a/python/src/xagent_sdk/tasks.py b/python/src/xagent_sdk/tasks.py index fba724e..57ec7f8 100644 --- a/python/src/xagent_sdk/tasks.py +++ b/python/src/xagent_sdk/tasks.py @@ -19,14 +19,20 @@ from xagent_sdk.agent_client import AgentClient -# Mirrors backend ``v1/tasks.py:170``: ``_TERMINAL_STATUSES = (COMPLETED, -# FAILED)``. PAUSED is *not* terminal -- backend allows append() onto a +# Mirrors the backend's terminal set (``v1/tasks.py``): only COMPLETED and +# FAILED. PAUSED is *not* terminal -- the backend allows append() onto a # PAUSED task (the atomic claim is ``WHERE status != RUNNING``), and # ``completed_at`` is only populated in COMPLETED/FAILED. SDK stays # consistent so multi-process workflows (A wait()s while B append()s a # resume) observe the RUNNING transition rather than return early. _TERMINAL_STATUSES = frozenset({TaskStatus.COMPLETED, TaskStatus.FAILED}) +# States where wait() stops polling and hands the task back to the caller: +# a terminal state, or WAITING_FOR_USER. The task cannot advance out of +# WAITING_FOR_USER on its own -- it blocks on *this* caller sending the next +# turn via append() -- so a passive poller would otherwise spin to timeout. +_WAIT_RETURN_STATUSES = _TERMINAL_STATUSES | frozenset({TaskStatus.WAITING_FOR_USER}) + class TasksAPI: """The ``client.tasks`` namespace. @@ -118,15 +124,18 @@ def wait( timeout: float = 120.0, poll_interval: float = 1.0, ) -> TaskInfo: - """Poll ``get()`` until the task reaches a terminal state. - - Terminal states are ``COMPLETED`` and ``FAILED`` -- mirroring the - backend's own definition. ``PENDING``, ``RUNNING``, and ``PAUSED`` - keep the loop going; a PAUSED task can be resumed by an + """Poll ``get()`` until the task stops needing the SDK to wait. + + Returns as soon as the task reaches a terminal state + (``COMPLETED``/``FAILED``) or ``WAITING_FOR_USER``. The latter is + not terminal but blocks on this caller sending the next turn, so + ``wait()`` hands it back: inspect ``status`` and ``append()`` the + user's reply, then ``wait()`` again. ``PENDING``, ``RUNNING``, and + ``PAUSED`` keep the loop going; a PAUSED task can be resumed by an ``append()`` from another caller, and waiting through it lets one observer see the resulting RUNNING transition. - Returns the final ``TaskInfo`` once a terminal state is observed. + Returns the ``TaskInfo`` once one of those states is observed. Raises ``TaskTimeout`` if the wall-clock deadline elapses first. Any other exception raised by ``get()`` (``XAgentTransportError``, ``TaskNotFound``, ``InvalidAPIKey``, ...) propagates immediately @@ -137,18 +146,18 @@ def wait( task_id: The task to poll. timeout: Maximum wall-clock seconds to wait. Must be non-negative; ``0`` polls exactly once and raises - ``TaskTimeout`` immediately if the task is still - non-terminal. Default 120. + ``TaskTimeout`` immediately if the task has not reached a + returnable state. Default 120. poll_interval: Seconds to sleep between polls. Must be non-negative; ``0`` tight-loops (yields the GIL each iteration via ``time.sleep(0)``). Default 1.0. Returns: - The final ``TaskInfo`` snapshot. + The ``TaskInfo`` snapshot at the returnable state. Raises: ValueError: ``timeout`` or ``poll_interval`` is negative. - TaskTimeout: ``timeout`` elapsed without a terminal state. + TaskTimeout: ``timeout`` elapsed without a returnable state. """ if timeout < 0: raise ValueError("timeout must be non-negative") @@ -157,14 +166,14 @@ def wait( deadline = time.monotonic() + timeout while True: info = self.get(task_id) - if info.status in _TERMINAL_STATUSES: + if info.status in _WAIT_RETURN_STATUSES: return info remaining = deadline - time.monotonic() if remaining <= 0: raise TaskTimeout( "task_timeout", ( - f"Task {task_id} did not reach a terminal state " + f"Task {task_id} did not reach a returnable state " f"within {timeout}s " f"(last observed status: {info.status.value})" ), @@ -194,16 +203,20 @@ def run( ``timeout`` is the wall-clock budget for ``create`` + ``wait`` combined: the time spent in ``create()`` is subtracted from the budget passed to ``wait()`` so the caller does not pay it twice. - ``steps()`` is invoked after a terminal state is observed and is - a single cheap GET; its latency is additional but expected to be - a small constant. - - Use the lower-level trio when you need to send multiple turns or - interleave other work. - - Raises ``TaskTimeout`` if the task does not terminate within the - combined ``create`` + ``wait`` budget. Other errors propagate - from the underlying ``create`` / ``get`` / ``steps`` calls. + ``steps()`` is invoked once ``wait()`` returns and is a single + cheap GET; its latency is additional but expected to be a small + constant. + + The returned ``RunResult`` is terminal (``COMPLETED``/``FAILED``) + unless the agent asked for input: a ``WAITING_FOR_USER`` status + means the task is paused on the next turn. Send it with + ``append()`` and ``wait()`` again -- or use the lower-level trio + directly when you need multiple turns or to interleave other work. + + Raises ``TaskTimeout`` if the task does not reach a returnable + state within the combined ``create`` + ``wait`` budget. Other + errors propagate from the underlying ``create`` / ``get`` / + ``steps`` calls. """ if timeout < 0: raise ValueError("timeout must be non-negative") diff --git a/python/src/xagent_sdk/types.py b/python/src/xagent_sdk/types.py index 1e39523..a55f684 100644 --- a/python/src/xagent_sdk/types.py +++ b/python/src/xagent_sdk/types.py @@ -11,12 +11,14 @@ class TaskStatus(StrEnum): """Lifecycle states a task can hold. - The full set the SDK may observe is fixed at 5 values. ``run()`` and - ``wait()`` treat only ``COMPLETED`` and ``FAILED`` as terminal, - mirroring the backend's own definition. ``PAUSED`` is non-terminal - because the backend allows ``append()`` onto a paused task, which - transitions it back to ``RUNNING``; a polling caller should observe - that transition rather than return early. + ``run()``/``wait()`` stop polling and return on a terminal state + (``COMPLETED``/``FAILED``) or on ``WAITING_FOR_USER`` -- the latter + blocks on this caller sending the next turn via ``append()``, so a + passive poller would never see it advance. ``PAUSED`` is non-terminal + and keeps the loop going: the backend allows ``append()`` onto a + paused task, transitioning it back to ``RUNNING``, and another caller + may drive that, so waiting through it lets an observer see the + transition. """ PENDING = "pending" @@ -24,6 +26,7 @@ class TaskStatus(StrEnum): COMPLETED = "completed" FAILED = "failed" PAUSED = "paused" + WAITING_FOR_USER = "waiting_for_user" class StepType(StrEnum): diff --git a/python/tests/unit/test_tasks.py b/python/tests/unit/test_tasks.py index 647185e..91211cf 100644 --- a/python/tests/unit/test_tasks.py +++ b/python/tests/unit/test_tasks.py @@ -323,7 +323,7 @@ def h(req: httpx.Request) -> httpx.Response: def test_paused_keeps_polling( self, make_client: Callable[..., AgentClient] ) -> None: - # PAUSED is NOT terminal (mirrors backend `v1/tasks.py:170`); + # PAUSED is NOT terminal (mirrors the backend's terminal set); # wait() should poll until the deadline elapses. def h(req: httpx.Request) -> httpx.Response: return httpx.Response( @@ -344,6 +344,37 @@ def h(req: httpx.Request) -> httpx.Response: c.tasks.wait(10, timeout=0.1, poll_interval=0.02) assert "paused" in excinfo.value.message + def test_waiting_for_user_returns_to_caller( + self, make_client: Callable[..., AgentClient] + ) -> None: + # WAITING_FOR_USER is non-terminal but blocks on this caller's + # next turn, so wait() stops polling and hands it back rather than + # spinning to timeout (unlike PAUSED above). + calls = {"n": 0} + + def h(req: httpx.Request) -> httpx.Response: + calls["n"] += 1 + status = "running" if calls["n"] < 2 else "waiting_for_user" + return httpx.Response( + 200, + json={ + "task_id": 10, + "agent_id": 7, + "status": status, + "input": "hi", + "output": None, + "error": None, + "created_at": "2026-05-10T03:00:00Z", + "completed_at": None, + }, + ) + + with make_client(h) as c: + info = c.tasks.wait(10, timeout=2.0, poll_interval=0.01) + + assert info.status is TaskStatus.WAITING_FOR_USER + assert calls["n"] == 2 + def test_propagates_404(self, make_client: Callable[..., AgentClient]) -> None: def h(req: httpx.Request) -> httpx.Response: return httpx.Response( diff --git a/python/tests/unit/test_types.py b/python/tests/unit/test_types.py index 0271073..2217932 100644 --- a/python/tests/unit/test_types.py +++ b/python/tests/unit/test_types.py @@ -94,6 +94,24 @@ def test_completed_with_values(self) -> None: assert t.output == "hello" assert isinstance(t, TaskInfo) + def test_waiting_for_user_accepted(self) -> None: + # waiting_for_user is a valid backend task status; the SDK must + # parse it rather than raising in the validation layer. + t = _parse_task_info( + { + "task_id": 10, + "agent_id": 7, + "status": "waiting_for_user", + "input": "hi", + "output": None, + "error": None, + "created_at": "2026-05-10T03:00:00Z", + "completed_at": None, + } + ) + assert t.status is TaskStatus.WAITING_FOR_USER + assert t.completed_at is None + def test_unknown_status_rejected(self) -> None: with pytest.raises(ValidationError): _parse_task_info(