Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down
61 changes: 37 additions & 24 deletions python/src/xagent_sdk/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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})"
),
Expand Down Expand Up @@ -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")
Expand Down
15 changes: 9 additions & 6 deletions python/src/xagent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,22 @@
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"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
PAUSED = "paused"
WAITING_FOR_USER = "waiting_for_user"


class StepType(StrEnum):
Expand Down
33 changes: 32 additions & 1 deletion python/tests/unit/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions python/tests/unit/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading