Skip to content
Open
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
412 changes: 412 additions & 0 deletions src/openrouter_agent/async_tool_registry.py

Large diffs are not rendered by default.

61 changes: 61 additions & 0 deletions src/openrouter_agent/async_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""The async-tool subsystem's façade: task lifecycle, the model-facing
``task`` tool, the registry of in-flight tasks, the concurrency primitives
that bound them, and the cancellation primitive they share.

Ports `packages/agent/src/lib/async-tools.ts` (re-exports only — no logic).
The engine imports the subsystem through here; anything needing a subset
should import the specific module directly. ``cancellation`` has no
upstream module (it replaces ``AbortSignal``) and is re-exported here so the
loop and tool contexts share one import.
"""

from __future__ import annotations

from .async_tool_registry import AsyncToolRegistry, SettledToolTask, build_task_result_envelope
from .cancellation import CancellationController, CancellationError, CancellationSignal
from .tool_check import (
TASK_TOOL_NAME,
TaskToolInput,
TaskToolInputSchema,
answer_task_tool_call,
build_pending_placeholder,
build_task_tool_api_definition,
build_task_tool_stub,
default_check_result,
has_task_tool_name_collision,
needs_task_tool,
persisted_task_check_result,
resolve_check_config,
task_tool_active,
)
from .tool_concurrency import Semaphore, acquire_all
from .tool_concurrency import Semaphore as ToolSemaphore
from .tool_task import TASK_RESULT_BOUNDARY, ToolTask, ToolTaskMode

__all__ = [
"TASK_RESULT_BOUNDARY",
"TASK_TOOL_NAME",
"AsyncToolRegistry",
"CancellationController",
"CancellationError",
"CancellationSignal",
"Semaphore",
"SettledToolTask",
"TaskToolInput",
"TaskToolInputSchema",
"ToolSemaphore",
"ToolTask",
"ToolTaskMode",
"acquire_all",
"answer_task_tool_call",
"build_pending_placeholder",
"build_task_result_envelope",
"build_task_tool_api_definition",
"build_task_tool_stub",
"default_check_result",
"has_task_tool_name_collision",
"needs_task_tool",
"persisted_task_check_result",
"resolve_check_config",
"task_tool_active",
]
209 changes: 209 additions & 0 deletions src/openrouter_agent/cancellation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
"""Cooperative cancellation primitive — the Python stand-in for `AbortSignal`.

Upstream (`@openrouter/agent` 0.9.0) threads a WHATWG `AbortSignal` through
every tool context (`ctx.signal`), each async task (`ToolTask.controller`),
and the run itself (`callModel({ signal })`). Per the port contract
(Idiomatic Divergences #5) cancellation here uses a Python-native mechanism
instead: a small, loop-agnostic signal object with the same observable
surface.

============================= ==========================================
TypeScript Python
============================= ==========================================
``new AbortController()`` ``CancellationController()``
``controller.abort(reason)`` ``controller.abort(reason)``
``signal.aborted`` ``signal.aborted``
``signal.reason`` ``signal.reason``
``signal.throwIfAborted()`` ``signal.throw_if_aborted()``
``addEventListener('abort')`` ``signal.add_listener(cb)`` (returns remover)
``AbortSignal.any([...])`` ``CancellationSignal.any([...])``
``AbortSignal.timeout(ms)`` ``CancellationSignal.timeout(ms)``
(no equivalent) ``await signal.wait()``
============================= ==========================================

Differences from `AbortSignal`, all deliberate:

- ``add_listener`` on an *already aborted* signal invokes the callback
immediately (``AbortSignal`` silently never fires it). A late registration
can therefore never miss the abort.
- ``wait()`` is an awaitable that resolves with the reason once aborted —
the natural way for an ``async`` tool body to race its work against
cancellation (``asyncio.wait({work, signal.wait()}, ...)``).
- The default reason is a :class:`CancellationError` (the analogue of the
``AbortError`` ``DOMException``).

The signal holds no event-loop reference until ``wait()`` / ``timeout()``
is called, so it is safe to construct outside a running loop.
"""

from __future__ import annotations

import asyncio
import warnings
from typing import Any, Callable, Iterable, List, Optional

__all__ = [
"CancellationController",
"CancellationError",
"CancellationListener",
"CancellationSignal",
]

#: Listener invoked with the abort reason.
CancellationListener = Callable[[Any], None]


class CancellationError(Exception):
"""Default abort reason (the analogue of the DOM ``AbortError``)."""

def __init__(self, message: str = "The operation was aborted") -> None:
super().__init__(message)


class CancellationSignal:
"""Read side of a cancellation: observe, never trigger.

Instances are created by :class:`CancellationController` (or the
:meth:`any` / :meth:`timeout` / :meth:`never` factories).
"""

__slots__ = ("_aborted", "_reason", "_listeners")

def __init__(self) -> None:
self._aborted = False
self._reason: Any = None
self._listeners: List[CancellationListener] = []

@property
def aborted(self) -> bool:
return self._aborted

@property
def reason(self) -> Any:
"""The abort reason, or ``None`` while not aborted."""
return self._reason

def throw_if_aborted(self) -> None:
"""Raise the reason (or a :class:`CancellationError` wrapping it) when aborted."""
if not self._aborted:
return
reason = self._reason
if isinstance(reason, BaseException):
raise reason
raise CancellationError(str(reason) if reason is not None else "The operation was aborted")

def add_listener(self, listener: CancellationListener) -> Callable[[], None]:
"""Register ``listener(reason)`` to run once on abort.

Returns a remover. On an already-aborted signal the listener runs
immediately (see the module docstring).
"""
if self._aborted:
listener(self._reason)
return _noop
self._listeners.append(listener)

def remove() -> None:
try:
self._listeners.remove(listener)
except ValueError:
pass

return remove

async def wait(self) -> Any:
"""Suspend until aborted; return the reason."""
if self._aborted:
return self._reason
loop = asyncio.get_running_loop()
future: asyncio.Future[Any] = loop.create_future()

def on_abort(reason: Any) -> None:
if not future.done():
future.set_result(reason)

remove = self.add_listener(on_abort)
try:
return await future
finally:
remove()

# -- internal -----------------------------------------------------------

def _abort(self, reason: Any) -> None:
if self._aborted:
return
self._aborted = True
self._reason = CancellationError() if reason is None else reason
listeners = self._listeners
self._listeners = []
for listener in listeners:
# Like EventTarget dispatch: one throwing listener must not
# starve the rest (they are how bodies learn to stop).
try:
listener(self._reason)
except Exception as error:
warnings.warn(f"[CancellationSignal] abort listener raised: {error!r}", stacklevel=2)

# -- factories ------------------------------------------------------------

@staticmethod
def never() -> CancellationSignal:
"""A signal that never aborts (upstream's "never-aborting signal")."""
return CancellationSignal()

@staticmethod
def any(signals: Iterable[CancellationSignal]) -> CancellationSignal:
"""Composite signal aborting when the first input aborts (``AbortSignal.any``)."""
controller = CancellationController()
sources = list(signals)
for source in sources:
if source.aborted:
controller.abort(source.reason)
return controller.signal
removers: List[Callable[[], None]] = []

def on_abort(reason: Any) -> None:
for remove in removers:
remove()
controller.abort(reason)

for source in sources:
removers.append(source.add_listener(on_abort))
return controller.signal

@staticmethod
def timeout(timeout_ms: float) -> CancellationSignal:
"""Signal aborting with a ``TimeoutError`` after ``timeout_ms`` (``AbortSignal.timeout``).

Requires a running event loop.
"""
controller = CancellationController()
loop = asyncio.get_running_loop()
handle = loop.call_later(
max(0.0, timeout_ms) / 1000,
lambda: controller.abort(TimeoutError(f"The operation timed out after {timeout_ms}ms")),
)
controller.signal.add_listener(lambda _reason: handle.cancel())
return controller.signal


class CancellationController:
"""Write side of a cancellation (the ``AbortController`` analogue)."""

__slots__ = ("_signal",)

def __init__(self) -> None:
self._signal = CancellationSignal()

@property
def signal(self) -> CancellationSignal:
return self._signal

def abort(self, reason: Optional[Any] = None) -> None:
"""Abort the signal. Idempotent: the first reason wins."""
self._signal._abort(reason)


def _noop() -> None:
return None
30 changes: 28 additions & 2 deletions src/openrouter_agent/chat_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ def _content_to_string(content: Any) -> str:


def from_chat_messages(messages: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
"""Convert OpenAI-Chat-style messages to OpenResponses input items.

Assistant `tool_calls` become `function_call` items (their already
serialized `arguments` are forwarded as-is). A message carrying both text
and tool calls emits both; an empty assistant message is omitted only when
tool calls replace it.
"""
output: List[Dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
Expand All @@ -27,8 +34,27 @@ def from_chat_messages(messages: Sequence[Mapping[str, Any]]) -> List[Dict[str,
"output": _content_to_string(msg.get("content")),
}
)
else:
output.append({"type": "message", "role": role, "content": _content_to_string(msg.get("content"))})
continue
if role == "assistant":
content = _content_to_string(msg.get("content"))
tool_calls = msg.get("tool_calls") or msg.get("toolCalls") or []
if content or not tool_calls:
output.append({"type": "message", "role": role, "content": content})
for tc in tool_calls:
fn = get_field(tc, "function", {}) or {}
call_id = get_field(tc, "id")
output.append(
{
"type": "function_call",
"callId": call_id,
"id": call_id,
"name": get_field(fn, "name"),
"arguments": get_field(fn, "arguments"),
"status": "completed",
}
)
continue
output.append({"type": "message", "role": role, "content": _content_to_string(msg.get("content"))})
return output


Expand Down
Loading
Loading