Skip to content
Merged
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "rewind-agent"
version = "0.16.1"
version = "0.17.0"
description = "Chrome DevTools for AI agents — record, inspect, fork, replay, diff."
readme = "README.md"
license = "MIT"
Expand Down
25 changes: 23 additions & 2 deletions python/rewind_agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,18 @@ def search(query):
wrap_crew,
)
from .cached_call import cached_llm_call
from .explicit import ExplicitClient, RewindReplayDivergenceError
from .explicit import (
ExplicitClient,
RewindReplayDivergenceError,
RewindServerError,
StepNotFoundError,
StepResponse,
cached_tool,
get_default_client,
set_default_client,
)
from . import connector
from .intercept import DefaultPredicates, Predicates
from .assertions import Assertions, AssertionResult
from .openai_agents import openai_agents_hooks
from .pydantic_ai import pydantic_ai_hooks
Expand Down Expand Up @@ -99,6 +109,17 @@ def search(query):
"cached_llm_call",
# One-call connector for any agent (see docs/hdk.md)
"connector",
# Intercept predicate types (for `connector.setup(predicates=…)`)
"Predicates",
"DefaultPredicates",
# Default-client discovery + module-level cached_tool decorator
"set_default_client",
"get_default_client",
"cached_tool",
# Public step-fetch helper (Phase 0 commit 3)
"StepResponse",
"StepNotFoundError",
"RewindServerError",
]


Expand All @@ -120,4 +141,4 @@ def import_from_langfuse(trace_id: str, **kwargs) -> str:
return _import(trace_id, **kwargs)


__version__ = "0.16.1"
__version__ = "0.17.0"
113 changes: 85 additions & 28 deletions python/rewind_agent/connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,14 @@
from contextlib import contextmanager
from typing import Iterator, Sequence

from rewind_agent.explicit import ExplicitClient
from rewind_agent.explicit import (
ExplicitClient,
get_default_client,
set_default_client,
)
from rewind_agent.intercept import (
DefaultPredicates,
Predicates,
install,
is_installed,
uninstall,
Expand Down Expand Up @@ -146,15 +151,17 @@ def setup(
*,
base_url: str | None = None,
llm_hosts: Sequence[str] | None = None,
predicates: Predicates | None = None,
enabled: bool | None = None,
thread_id: str | None = None,
metadata: dict | None = None,
) -> Iterator[ExplicitClient | None]:
"""Connect any agent to Rewind for the duration of a ``with`` block.

Starts a session, installs HTTP intercept (with custom predicates
when ``llm_hosts`` is set), yields the :class:`ExplicitClient` for
use inside the block, and tears both down on exit.
when ``llm_hosts`` is set or ``predicates`` is provided), yields the
:class:`ExplicitClient` for use inside the block, and tears both
down on exit.

Parameters
----------
Expand All @@ -168,47 +175,97 @@ def setup(
Sequence of hostnames to treat as LLM gateways. ``None``
(default) reads ``$REWIND_LLM_HOSTS``; empty / unset falls
through to intercept's strict-by-default provider list.
Mutually exclusive with ``predicates``.
predicates:
Fully custom :class:`~rewind_agent.intercept.Predicates`
instance forwarded directly to :func:`intercept.install`. Use
when hostname-substring matching is not enough (e.g. matching
only specific path prefixes, or routing decisions that depend
on headers). Mutually exclusive with ``llm_hosts`` — passing
both raises :class:`ValueError` rather than silently picking
a winner.
enabled:
``None`` (default) reads ``$REWIND_ENABLED`` (any value other
than ``"0"`` is on); ``True`` forces on; ``False`` forces off.
When off, ``setup()`` is a true no-op — yields ``None``, no HTTP,
no install.
thread_id, metadata:
Forwarded to :meth:`ExplicitClient.session`.
Forwarded to :meth:`ExplicitClient.session`. Ignored on the
replay-dispatch path — when ``REWIND_SESSION_ID`` etc. are set,
``setup()`` attaches to the existing session instead of starting
a new one, so per-session metadata has no effect.

Yields
------
ExplicitClient | None
The recording client, or ``None`` when disabled.

Raises
------
ValueError
When both ``predicates=`` and ``llm_hosts=`` are provided.
TypeError
When ``predicates=`` is not a :class:`~rewind_agent.intercept.Predicates`
instance (catches the common typo of passing a callable, a string,
or a list of hostnames).
"""
if predicates is not None and llm_hosts is not None:
raise ValueError(
"setup() accepts either `predicates=` or `llm_hosts=`, not both. "
"Use `predicates=` for fully custom matching; use `llm_hosts=` "
"for the hostname-substring shortcut."
)
if predicates is not None and not isinstance(predicates, Predicates):
# Boundary check parity with set_default_client(): catches the
# common typos of passing a callable, a string, or a list of
# hostnames where a Predicates instance was expected. Predicates
# is a runtime_checkable Protocol, so duck-typed instances are
# accepted.
raise TypeError(
f"setup(predicates=...) expected a Predicates instance, "
f"got {type(predicates).__name__}"
)

if not _enabled(enabled):
yield None
return

# base_url resolution lives in ExplicitClient.__init__ so all callers
# share a single source of truth (kwarg > $REWIND_URL > localhost).
client = ExplicitClient(base_url=base_url)
hosts = _resolve_hosts(llm_hosts)
predicates = _HostPredicates(hosts) if hosts else None

if _is_replay_dispatch():
# Runner-driven replay: intercept.install() will attach to the
# existing replay context via env vars. Don't create a phantom
# session.
already_installed = is_installed()
install(predicates=predicates)
try:
yield client
finally:
if not already_installed:
uninstall()
return

with client.session(name, thread_id=thread_id, metadata=metadata):
already_installed = is_installed()
install(predicates=predicates)
try:
yield client
finally:
if not already_installed:
uninstall()
if predicates is None:
hosts = _resolve_hosts(llm_hosts)
predicates = _HostPredicates(hosts) if hosts else None

# Stack-semantics for the default-client binding: save the previous
# value (which may be a different client or None), bind ours for the
# duration of the block, restore on exit — even if install() or
# client.session().__enter__ raises. The outer try/finally below
# guarantees the module-global never stays polluted across a setup()
# failure.
previous_default = get_default_client()
set_default_client(client)
try:
if _is_replay_dispatch():
# Runner-driven replay: intercept.install() will attach to the
# existing replay context via env vars. Don't create a phantom
# session.
already_installed = is_installed()
install(predicates=predicates)
try:
yield client
finally:
if not already_installed:
uninstall()
return

with client.session(name, thread_id=thread_id, metadata=metadata):
already_installed = is_installed()
install(predicates=predicates)
try:
yield client
finally:
if not already_installed:
uninstall()
finally:
set_default_client(previous_default)
Loading
Loading