Files
wehub-resource-sync 85742ab165
CPU Test / Lint - next (push) Waiting to run
Dashboard / Chromatic (push) Waiting to run
CPU Test / Lint - fast (push) Waiting to run
CPU Test / Build documentation (push) Waiting to run
CPU Test / Test (Store, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Weave, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Others, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Store, latest, Python 3.13) (push) Waiting to run
CPU Test / Lint - slow (push) Waiting to run
CPU Test / Lint - JavaScript (push) Waiting to run
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Others, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Weave, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (JavaScript) (push) Waiting to run
Deploy Documentation / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

1331 lines
47 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import random
import time
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Dict, List, Literal, Optional, Sequence, Tuple, cast
import pytest
from opentelemetry import trace as trace_api
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
from opentelemetry.trace import SpanContext, TraceFlags, TraceState
from opentelemetry.trace.status import Status, StatusCode
from agentlightning.execution.events import ExecutionEvent, ThreadingEvent
from agentlightning.litagent import LitAgent
from agentlightning.reward import emit_reward, find_final_reward
from agentlightning.runner import LitAgentRunner
from agentlightning.runner.base import Runner
from agentlightning.semconv import AGL_ANNOTATION
from agentlightning.store.base import UNSET, LightningStore, Unset
from agentlightning.store.memory import InMemoryLightningStore
from agentlightning.tracer.base import Tracer
from agentlightning.types import LLM, Hook, NamedResources, PromptTemplate, Rollout, Span, SpanCoreFields, Worker
@pytest.fixture(scope="module", autouse=True)
def setup_module():
trace_api.set_tracer_provider(TracerProvider())
yield
def create_readable_span(name: str, attributes: Optional[Dict[str, Any]] = None) -> ReadableSpan:
trace_id = random.getrandbits(128)
span_id = random.getrandbits(64)
context = SpanContext(
trace_id=trace_id,
span_id=span_id,
is_remote=False,
trace_flags=TraceFlags(TraceFlags.SAMPLED),
trace_state=TraceState(),
)
status = Status(status_code=StatusCode.UNSET)
return ReadableSpan(
name=name,
context=context,
parent=None,
resource=Resource.create({}),
attributes=attributes or {},
events=(),
links=(),
status=status,
)
def create_agent_span(
rollout_id: str,
attempt_id: str,
sequence_id: int,
name: str,
attributes: Optional[Dict[str, Any]] = None,
) -> Span:
readable = create_readable_span(name, attributes)
return Span.from_opentelemetry(
readable,
rollout_id=rollout_id,
attempt_id=attempt_id,
sequence_id=sequence_id,
)
class DummyTracer(Tracer):
def __init__(self) -> None:
super().__init__()
self._last_trace: List[Span] = []
self._contexts: List[Dict[str, Any]] = []
self._sequence_id = 0
def init(self, *args: Any, **kwargs: Any) -> None:
self._last_trace.clear()
def teardown(self, *args: Any, **kwargs: Any) -> None:
self._last_trace.clear()
def get_last_trace(self) -> List[Span]:
return list(self._last_trace)
@asynccontextmanager
async def trace_context(
self,
name: Optional[str] = None,
*,
store: Optional[LightningStore] = None,
rollout_id: Optional[str] = None,
attempt_id: Optional[str] = None,
) -> AsyncGenerator[List[Span], None]:
previous = self._contexts[-1] if self._contexts else None
current = {
"name": name,
"store": store,
"rollout_id": rollout_id,
"attempt_id": attempt_id,
}
self._contexts.append(current)
self._last_trace = []
try:
yield self._last_trace
finally:
self._contexts.pop()
if previous is None:
self._contexts = []
def record_span(self, name: str, attributes: Optional[Dict[str, Any]] = None) -> ReadableSpan:
span = create_readable_span(name, attributes)
rollout_id = "rollout-dummy"
attempt_id = "attempt-dummy"
sequence_id = self._sequence_id
self._sequence_id += 1
if self._contexts:
current = self._contexts[-1]
rollout_id = current["rollout_id"]
attempt_id = current["attempt_id"]
self._last_trace.append(Span.from_opentelemetry(span, rollout_id, attempt_id, sequence_id))
return span
class RecordingStore(InMemoryLightningStore):
"""In-memory store that records worker heartbeat updates for inspection in tests."""
def __init__(self) -> None:
super().__init__()
self.worker_updates: List[Tuple[str, Optional[Dict[str, Any]]]] = []
async def update_worker(
self,
worker_id: str,
heartbeat_stats: Dict[str, Any] | Unset = UNSET,
) -> Worker:
payload = None if isinstance(heartbeat_stats, Unset) else heartbeat_stats
self.worker_updates.append((worker_id, payload))
return await super().update_worker(worker_id, heartbeat_stats=heartbeat_stats)
class HeartbeatAgent(LitAgent[Dict[str, Any]]):
"""Minimal agent used for heartbeat-only runner tests."""
def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
return 0.0
async def setup_heartbeat_runner(
*,
heartbeat_interval: float = 0.05,
heartbeat_launch_mode: Literal["asyncio", "thread"] = "thread",
heartbeat_include_gpu: bool = False,
) -> tuple[LitAgentRunner[Any], RecordingStore]:
"""Create a runner wired to a RecordingStore for heartbeat tests."""
store = RecordingStore()
runner = LitAgentRunner[Any](
tracer=DummyTracer(),
heartbeat_interval=heartbeat_interval,
heartbeat_launch_mode=heartbeat_launch_mode,
heartbeat_include_gpu=heartbeat_include_gpu,
)
agent = HeartbeatAgent()
runner.init(agent)
runner.init_worker(worker_id=0, store=store)
return runner, store
async def setup_runner(
agent: LitAgent[Any],
*,
tracer: Optional[DummyTracer] = None,
max_rollouts: Optional[int] = None,
poll_interval: float = 0.01,
hooks: Sequence[Hook] = (),
) -> tuple[LitAgentRunner[Any], InMemoryLightningStore, DummyTracer]:
tracer = tracer or DummyTracer()
store = InMemoryLightningStore()
await store.update_resources("default", {"llm": LLM(endpoint="http://localhost", model="dummy")})
runner = LitAgentRunner[Any](tracer=tracer, max_rollouts=max_rollouts, poll_interval=poll_interval)
runner.init(agent=agent, hooks=hooks)
runner.init_worker(worker_id=0, store=store)
return runner, store, tracer
def teardown_runner(runner: LitAgentRunner[Any]) -> None:
runner.teardown_worker(worker_id=0)
runner.teardown()
async def assert_single_attempt_succeeded(store: InMemoryLightningStore) -> tuple[str, str]:
rollouts = await store.query_rollouts()
assert len(rollouts) == 1
rollout = rollouts[0]
attempts = await store.query_attempts(rollout.rollout_id)
assert attempts[-1].status == "succeeded"
return rollout.rollout_id, attempts[-1].attempt_id
class RecordingHook(Hook):
def __init__(self) -> None:
super().__init__()
self.calls: List[str] = []
self.received_spans: Optional[List[ReadableSpan] | List[Span]] = None
async def on_rollout_start(self, *, agent: LitAgent[Any], runner: Runner[Any], rollout: Rollout) -> None:
self.calls.append("on_rollout_start")
async def on_trace_start(
self, *, agent: LitAgent[Any], runner: Runner[Any], tracer: Tracer, rollout: Rollout
) -> None:
self.calls.append("on_trace_start")
async def on_trace_end(
self, *, agent: LitAgent[Any], runner: Runner[Any], tracer: Tracer, rollout: Rollout
) -> None:
self.calls.append("on_trace_end")
async def on_rollout_end(
self,
*,
agent: LitAgent[Any],
runner: Runner[Any],
rollout: Rollout,
spans: List[ReadableSpan] | List[Span],
) -> None:
self.calls.append("on_rollout_end")
self.received_spans = spans
@pytest.mark.asyncio
async def test_step_records_spans_for_none_result() -> None:
tracer = DummyTracer()
class AsyncSpanAgent(LitAgent[Dict[str, Any]]):
async def validation_rollout_async(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> None:
span = tracer.record_span("work", {"task_id": task["task_id"]})
store = cast(LitAgentRunner[Dict[str, Any]], self.runner).get_store()
await store.add_otel_span(rollout.rollout_id, rollout.attempt.attempt_id, span) # type: ignore[attr-defined]
return None
agent = AsyncSpanAgent()
runner, store, _ = await setup_runner(agent, tracer=tracer)
try:
await runner.step({"task_id": 1})
finally:
teardown_runner(runner)
rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
spans = await store.query_spans(rollout_id, attempt_id)
assert [span.name for span in spans] == ["work"]
assert find_final_reward(spans) is None
@pytest.mark.asyncio
async def test_step_emits_reward_for_float_result() -> None:
class RewardAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
return 0.75
agent = RewardAgent()
runner, store, _ = await setup_runner(agent)
try:
await runner.step({"prompt": "hello"})
finally:
teardown_runner(runner)
rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
spans = await store.query_spans(rollout_id, attempt_id)
rewards = [span.attributes.get("agentlightning.reward.0.value") for span in spans if span.name == AGL_ANNOTATION]
assert rewards == [0.75]
@pytest.mark.asyncio
async def test_step_emits_reward_for_bool_result(caplog: pytest.LogCaptureFixture) -> None:
class BoolRewardAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> bool:
return True
agent = BoolRewardAgent()
runner, store, _ = await setup_runner(agent)
caplog.set_level(logging.WARNING)
try:
await runner.step({"prompt": "hello"})
finally:
teardown_runner(runner)
assert "Reward is not a number" in caplog.text
rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
spans = await store.query_spans(rollout_id, attempt_id)
rewards = [span.attributes.get("agentlightning.reward.0.value") for span in spans if span.name == AGL_ANNOTATION]
assert rewards == [1.0]
@pytest.mark.asyncio
async def test_step_raises_for_invalid_result_type() -> None:
class InvalidResultAgent(LitAgent[Dict[str, Any]]):
def validation_rollout( # type: ignore[reportIncompatibleMethodOverride]
self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
) -> Dict[str, Any]:
return {"unexpected": True}
agent = InvalidResultAgent()
runner, store, _ = await setup_runner(agent)
try:
with pytest.raises(TypeError, match="Invalid raw result type"):
await runner.step({"task": "bad-result"})
finally:
teardown_runner(runner)
rollouts = await store.query_rollouts()
assert len(rollouts) == 1
attempts = await store.query_attempts(rollouts[0].rollout_id)
assert attempts[-1].status == "failed"
@pytest.mark.asyncio
async def test_readable_spans_return_skip_store_when_tracer_is_otel(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
class DummyTracerWithOtel(DummyTracer):
pass
class RecordingStore(InMemoryLightningStore):
def __init__(self) -> None:
super().__init__()
self.add_otel_span_calls = 0
async def add_otel_span(
self, rollout_id: str, attempt_id: str, readable_span: ReadableSpan, sequence_id: int | None = None
) -> Span | None:
self.add_otel_span_calls += 1
return await super().add_otel_span(rollout_id, attempt_id, readable_span, sequence_id)
monkeypatch.setattr("agentlightning.runner.agent.OtelTracer", DummyTracerWithOtel)
store = RecordingStore()
await store.update_resources("default", {"llm": LLM(endpoint="http://localhost", model="dummy")})
tracer = DummyTracerWithOtel()
runner = LitAgentRunner[Any](tracer=tracer)
agent = HeartbeatAgent()
runner.init(agent)
runner.init_worker(worker_id=0, store=store)
attempted_rollout = await store.start_rollout(input={"task": "otel"}, mode="val")
spans = [create_readable_span("otel-span")]
result_spans = await runner._post_process_rollout_result( # pyright: ignore[reportPrivateUsage]
attempted_rollout, spans
)
assert all(isinstance(span, Span) for span in result_spans)
# Warned, but still logged
assert [span.name for span in result_spans] == ["otel-span"]
assert store.add_otel_span_calls == 1
assert "Tracer is already an OpenTelemetry tracer" in caplog.text
teardown_runner(runner)
@pytest.mark.asyncio
async def test_post_process_readable_spans_adds_each_span() -> None:
agent = HeartbeatAgent()
runner, store, _ = await setup_runner(agent)
attempted_rollout = await store.start_rollout(input={"task": "post-process"}, mode="val")
spans = [create_readable_span("case-2-span-a"), create_readable_span("case-2-span-b")]
try:
result_spans = await runner._post_process_rollout_result( # pyright: ignore[reportPrivateUsage]
attempted_rollout, spans
)
finally:
teardown_runner(runner)
assert [span.name for span in result_spans] == ["case-2-span-a", "case-2-span-b"]
stored_spans = await store.query_spans(attempted_rollout.rollout_id, attempted_rollout.attempt.attempt_id)
assert [span.name for span in stored_spans] == ["case-2-span-a", "case-2-span-b"]
@pytest.mark.asyncio
async def test_post_process_span_core_fields_create_spans() -> None:
agent = HeartbeatAgent()
runner, store, _ = await setup_runner(agent)
attempted_rollout = await store.start_rollout(input={"task": "reward-list"}, mode="val")
span_core_fields = [emit_reward(0.5, propagate=False), emit_reward(-0.2, propagate=False)]
try:
result_spans = await runner._post_process_rollout_result( # pyright: ignore[reportPrivateUsage]
attempted_rollout, span_core_fields
)
finally:
teardown_runner(runner)
assert all(span.name == AGL_ANNOTATION for span in result_spans)
stored_spans = await store.query_spans(attempted_rollout.rollout_id, attempted_rollout.attempt.attempt_id)
assert [span.attributes.get("agentlightning.reward.0.value") for span in stored_spans] == [0.5, -0.2]
@pytest.mark.asyncio
async def test_step_handles_non_llm_resource() -> None:
class PromptAgent(LitAgent[str]):
def validation_rollout(self, task: str, resources: Dict[str, Any], rollout: Any) -> float:
template = resources["template"]
assert isinstance(template, PromptTemplate)
rendered = template.template.format(name=task)
assert task in rendered
return 0.1
agent = PromptAgent()
runner, store, _ = await setup_runner(agent)
try:
await store.update_resources(
"prompt-resource",
{"template": PromptTemplate(template="Hello {name}!", engine="f-string")},
)
await runner.step("Ada")
finally:
teardown_runner(runner)
rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
spans = await store.query_spans(rollout_id, attempt_id)
rewards = [span.attributes.get("agentlightning.reward.0.value") for span in spans if span.name == AGL_ANNOTATION]
assert rewards == [0.1]
@pytest.mark.asyncio
async def test_step_accepts_readable_span_list() -> None:
class ReadableSpanAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(
self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
) -> List[ReadableSpan]:
return [create_readable_span(f"trace-{i}", {"idx": i}) for i in range(2)]
agent = ReadableSpanAgent()
runner, store, _ = await setup_runner(agent)
try:
await runner.step({"payload": True})
finally:
teardown_runner(runner)
rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
spans = await store.query_spans(rollout_id, attempt_id)
assert [span.name for span in spans] == ["trace-0", "trace-1"]
@pytest.mark.asyncio
async def test_step_accepts_agent_span_list() -> None:
class AgentSpanAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> List[Span]:
return [
create_agent_span(rollout.rollout_id, rollout.attempt.attempt_id, 1, "custom-1", {"order": 1}),
create_agent_span(rollout.rollout_id, rollout.attempt.attempt_id, 2, "custom-2", {"order": 2}),
]
agent = AgentSpanAgent()
runner, store, _ = await setup_runner(agent)
try:
await runner.step({"payload": False})
finally:
teardown_runner(runner)
rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
spans = await store.query_spans(rollout_id, attempt_id)
assert [span.name for span in spans] == ["custom-1", "custom-2"]
@pytest.mark.asyncio
async def test_iter_respects_max_rollouts() -> None:
class CountingAgent(LitAgent[Dict[str, Any]]):
def __init__(self) -> None:
super().__init__()
self.processed: List[int] = []
def training_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
self.processed.append(task["idx"])
return 0.0
agent = CountingAgent()
runner, store, _ = await setup_runner(agent, max_rollouts=2)
for idx in range(3):
await store.enqueue_rollout({"idx": idx}, mode="train")
try:
await asyncio.wait_for(runner.iter(), timeout=1)
finally:
teardown_runner(runner)
assert agent.processed == [0, 1]
rollouts = await store.query_rollouts()
statuses = {rollout.rollout_id: rollout.status for rollout in rollouts}
assert list(statuses.values()).count("succeeded") == 2
@pytest.mark.asyncio
async def test_iter_stops_when_event_is_set() -> None:
stop_event = ThreadingEvent()
class StoppableAgent(LitAgent[Dict[str, Any]]):
def __init__(self) -> None:
super().__init__()
self.processed: List[int] = []
async def training_rollout_async(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> None:
self.processed.append(task["idx"])
if len(self.processed) == 1:
stop_event.set()
await asyncio.sleep(0.05)
return None
agent = StoppableAgent()
runner, store, _ = await setup_runner(agent)
for idx in range(3):
await store.enqueue_rollout({"idx": idx}, mode="train")
iter_task = asyncio.create_task(runner.iter(event=stop_event))
try:
await asyncio.wait_for(asyncio.to_thread(stop_event.wait, timeout=1), timeout=2)
await asyncio.wait_for(iter_task, timeout=1)
finally:
teardown_runner(runner)
assert agent.processed == [0]
rollouts = await store.query_rollouts()
succeeded = [rollout for rollout in rollouts if rollout.status == "succeeded"]
assert len(succeeded) == 1
@pytest.mark.asyncio
async def test_iter_waits_when_queue_empty_calls_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
stop_event = ThreadingEvent()
class IdleAgent(LitAgent[Any]):
def training_rollout(self, task: Any, resources: Any, rollout: Any) -> None:
return None
agent = IdleAgent()
runner, _, _ = await setup_runner(agent, poll_interval=0.01)
sleep_calls = 0
async def fake_sleep(event: Optional[ExecutionEvent] = None) -> None:
nonlocal sleep_calls
sleep_calls += 1
if event is not None:
event.set()
monkeypatch.setattr(runner, "_sleep_until_next_poll", fake_sleep)
try:
await runner.iter(event=stop_event)
finally:
teardown_runner(runner)
assert sleep_calls >= 1
@pytest.mark.asyncio
async def test_async_validation_rollout_used() -> None:
class AsyncValidationAgent(LitAgent[Dict[str, Any]]):
def __init__(self) -> None:
super().__init__()
self.validation_calls = 0
self.training_calls = 0
async def validation_rollout_async(
self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
) -> float:
self.validation_calls += 1
return 0.0
async def training_rollout_async(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
self.training_calls += 1
return 0.0
agent = AsyncValidationAgent()
runner, store, _ = await setup_runner(agent, max_rollouts=1)
await store.enqueue_rollout({"idx": 1}, mode="val")
try:
await runner.iter()
finally:
teardown_runner(runner)
assert agent.validation_calls == 1
assert agent.training_calls == 0
@pytest.mark.asyncio
async def test_training_rollout_sync_used() -> None:
class SyncTrainingAgent(LitAgent[Dict[str, Any]]):
def __init__(self) -> None:
super().__init__()
self.training_calls = 0
def training_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> None:
self.training_calls += 1
return None
agent = SyncTrainingAgent()
runner, store, _ = await setup_runner(agent, max_rollouts=1)
await store.enqueue_rollout({"idx": 99}, mode="train")
try:
await runner.iter()
finally:
teardown_runner(runner)
assert agent.training_calls == 1
@pytest.mark.asyncio
async def test_step_handles_agent_exception_marks_attempt_failed() -> None:
class FailingAgent(LitAgent[Dict[str, Any]]):
def training_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> None:
raise RuntimeError("boom")
agent = FailingAgent()
runner, store, _ = await setup_runner(agent)
with pytest.raises(RuntimeError):
await runner.step({"task": "x"})
rollouts = await store.query_rollouts()
assert len(rollouts) == 1
attempts = await store.query_attempts(rollouts[0].rollout_id)
assert attempts[-1].status == "failed"
teardown_runner(runner)
@pytest.mark.asyncio
async def test_agent_emits_multiple_rewards() -> None:
class RewardListAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(
self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
) -> List[SpanCoreFields]:
return [emit_reward(0.2, propagate=False), emit_reward(0.6, propagate=False)]
agent = RewardListAgent()
runner, store, _ = await setup_runner(agent)
try:
await runner.step({"task": "reward"})
finally:
teardown_runner(runner)
rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
spans = await store.query_spans(rollout_id, attempt_id)
reward_values = [
span.attributes.get("agentlightning.reward.0.value") for span in spans if span.name == AGL_ANNOTATION
]
assert reward_values == [0.2, 0.6]
@pytest.mark.asyncio
async def test_hooks_triggered_in_order() -> None:
hook = RecordingHook()
class HookAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(
self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
) -> List[ReadableSpan]:
return [create_readable_span("hook-span")]
agent = HookAgent()
runner, store, _ = await setup_runner(agent, hooks=[hook])
try:
await runner.step({"task": "hook"})
finally:
teardown_runner(runner)
assert hook.calls == ["on_rollout_start", "on_trace_start", "on_trace_end", "on_rollout_end"]
assert hook.received_spans is not None
rollout_id, attempt_id = await assert_single_attempt_succeeded(store)
spans = await store.query_spans(rollout_id, attempt_id)
assert [span.name for span in spans] == ["hook-span"]
@pytest.mark.asyncio
async def test_step_returns_completed_rollout() -> None:
"""Test that step() returns a Rollout object after execution."""
class SimpleAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
return 0.85
agent = SimpleAgent()
runner, store, _ = await setup_runner(agent)
try:
result = await runner.step({"task": "test"})
finally:
teardown_runner(runner)
# Verify the result is a Rollout object
assert isinstance(result, Rollout)
assert result.status == "succeeded"
assert result.input == {"task": "test"}
# Verify the rollout was stored correctly
rollouts = await store.query_rollouts()
assert len(rollouts) == 1
assert rollouts[0].rollout_id == result.rollout_id
@pytest.mark.asyncio
async def test_step_returns_rollout_with_spans() -> None:
"""Test that the returned rollout can be used to query spans."""
class SpanAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(
self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
) -> List[ReadableSpan]:
return [create_readable_span("test-span-1"), create_readable_span("test-span-2")]
agent = SpanAgent()
runner, store, _ = await setup_runner(agent)
try:
result = await runner.step({"task": "test"})
finally:
teardown_runner(runner)
# Verify we can query spans using the returned rollout
attempts = await store.query_attempts(result.rollout_id)
assert len(attempts) > 0
spans = await store.query_spans(result.rollout_id, attempts[-1].attempt_id)
assert len(spans) == 2
assert [span.name for span in spans] == ["test-span-1", "test-span-2"]
@pytest.mark.asyncio
async def test_step_raises_when_rollout_fetch_fails(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that step() raises RuntimeError when completed rollout cannot be fetched."""
class SimpleAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
return 0.5
agent = SimpleAgent()
runner, store, _ = await setup_runner(agent)
# Mock get_rollout_by_id to return None
original_get_rollout = store.get_rollout_by_id
async def mock_get_rollout_by_id(rollout_id: str) -> Optional[Rollout]:
return None
monkeypatch.setattr(store, "get_rollout_by_id", mock_get_rollout_by_id)
try:
with pytest.raises(RuntimeError, match="Failed to fetch completed rollout by id after step"):
await runner.step({"task": "test"})
finally:
monkeypatch.setattr(store, "get_rollout_by_id", original_get_rollout)
teardown_runner(runner)
@pytest.mark.asyncio
async def test_step_impl_returns_rollout_id() -> None:
"""Test that _step_impl returns the rollout_id after execution."""
class SimpleAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
return 0.9
agent = SimpleAgent()
runner, store, _ = await setup_runner(agent)
# Create an attempted rollout
attempted_rollout = await store.start_rollout(input={"task": "test"}, mode="val")
try:
# Call _step_impl directly and verify it returns rollout_id
result = await runner._step_impl( # pyright: ignore[reportPrivateUsage]
attempted_rollout, raise_on_exception=True
)
finally:
teardown_runner(runner)
# Verify the result is a string (rollout_id)
assert isinstance(result, str)
assert result == attempted_rollout.rollout_id
@pytest.mark.asyncio
async def test_step_impl_returns_rollout_id_on_resource_failure() -> None:
"""Test that _step_impl returns rollout_id even when resources fail to fetch."""
class SimpleAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
return 0.9
agent = SimpleAgent()
runner, store, _ = await setup_runner(agent)
# Create an attempted rollout with invalid resources_id
attempted_rollout = await store.start_rollout(input={"task": "test"}, mode="val", resources_id="invalid-id")
try:
# Call _step_impl with raise_on_exception=False (to test the early return path)
result = await runner._step_impl( # pyright: ignore[reportPrivateUsage]
attempted_rollout, raise_on_exception=False
)
finally:
teardown_runner(runner)
# Verify the result is a string (rollout_id) even on early return
assert isinstance(result, str)
assert result == attempted_rollout.rollout_id
@pytest.mark.asyncio
async def test_step_with_custom_resources_returns_rollout() -> None:
"""Test that step() with custom resources returns a valid Rollout."""
class ResourceAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
# Verify we received the custom LLM
llm = resources.get("llm")
assert llm is not None
assert llm.model == "custom-model"
return 0.95
agent = ResourceAgent()
runner, _store, _ = await setup_runner(agent)
custom_resources: NamedResources = {"llm": LLM(endpoint="http://custom", model="custom-model")}
try:
result = await runner.step({"task": "test"}, resources=custom_resources)
finally:
teardown_runner(runner)
# Verify the result is a valid Rollout
assert isinstance(result, Rollout)
assert result.status == "succeeded"
assert result.input == {"task": "test"}
# Verify the rollout has the correct resources_id
assert result.resources_id is not None
@pytest.mark.asyncio
async def test_step_registers_worker_id_on_start_rollout(monkeypatch: pytest.MonkeyPatch) -> None:
"""runner.step should pass the formatted worker ID down to the store."""
class WorkerAwareAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any) -> float:
return 1.0
agent = WorkerAwareAgent()
runner, store, _ = await setup_runner(agent)
expected_worker_label = runner.get_worker_id()
captured: Dict[str, Optional[str]] = {}
original_start_rollout = store.start_rollout
async def wrapped_start_rollout(*args: Any, **kwargs: Any):
captured["worker_id"] = kwargs.get("worker_id")
return await original_start_rollout(*args, **kwargs)
monkeypatch.setattr(store, "start_rollout", wrapped_start_rollout)
try:
await runner.step({"task": "worker-aware"})
finally:
teardown_runner(runner)
assert captured["worker_id"] == expected_worker_label
@pytest.mark.asyncio
async def test_iter_passes_worker_id_to_dequeue(monkeypatch: pytest.MonkeyPatch) -> None:
"""iter() should poll the store with the formatted worker identifier."""
class IdleAgent(LitAgent[Dict[str, Any]]):
def validation_rollout(
self, task: Dict[str, Any], resources: Dict[str, Any], rollout: Any
) -> float: # pragma: no cover - not invoked
return 0.0
agent = IdleAgent()
runner, store, _ = await setup_runner(agent, poll_interval=0.01)
expected_worker_label = runner.get_worker_id()
captured: Dict[str, Optional[str]] = {}
event = ThreadingEvent()
async def fake_dequeue(*, worker_id: Optional[str] = None):
captured["worker_id"] = worker_id
event.set()
return None
async def fast_sleep(self: LitAgentRunner[Any], event: Optional[ExecutionEvent] = None) -> None:
if event is not None:
event.set()
monkeypatch.setattr(store, "dequeue_rollout", fake_dequeue)
monkeypatch.setattr(LitAgentRunner, "_sleep_until_next_poll", fast_sleep)
try:
await runner.iter(event=event)
finally:
teardown_runner(runner)
assert captured["worker_id"] == expected_worker_label
@pytest.mark.asyncio
async def test_emit_heartbeat_updates_worker_snapshot(monkeypatch: pytest.MonkeyPatch) -> None:
snapshot = {"cpu_pct": 42.0, "mem_pct": 10.5}
monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", lambda include_gpu=False: snapshot)
runner, store = await setup_heartbeat_runner(heartbeat_interval=0.1)
worker_label = runner.get_worker_id()
try:
await runner._emit_heartbeat(store) # pyright: ignore[reportPrivateUsage]
finally:
teardown_runner(runner)
assert store.worker_updates == [(worker_label, snapshot)]
worker = await store.get_worker_by_id(worker_label)
assert worker is not None
assert worker.heartbeat_stats == snapshot
assert worker.last_heartbeat_time is not None
@pytest.mark.asyncio
async def test_emit_heartbeat_passes_include_gpu(monkeypatch: pytest.MonkeyPatch) -> None:
snapshot = {"cpu_pct": 11.1}
requested_flags: List[bool] = []
async def immediate_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
monkeypatch.setattr("agentlightning.runner.agent.asyncio.to_thread", immediate_to_thread)
def fake_system_snapshot(include_gpu: bool = False) -> Dict[str, Any]:
requested_flags.append(include_gpu)
return snapshot
monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", fake_system_snapshot)
runner, store = await setup_heartbeat_runner(heartbeat_interval=0.05, heartbeat_include_gpu=True)
worker_label = runner.get_worker_id()
try:
await runner._emit_heartbeat(store) # pyright: ignore[reportPrivateUsage]
finally:
teardown_runner(runner)
assert requested_flags == [True]
assert store.worker_updates == [(worker_label, snapshot)]
@pytest.mark.asyncio
async def test_emit_heartbeat_skips_when_snapshot_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
snapshot = {"cpu_pct": 9.9}
runner, store = await setup_heartbeat_runner(heartbeat_interval=0.05)
interval = runner._heartbeat_interval # pyright: ignore[reportPrivateUsage]
async def slow_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
await asyncio.sleep(interval + 0.05)
return func(*args, **kwargs)
monkeypatch.setattr("agentlightning.runner.agent.asyncio.to_thread", slow_to_thread)
monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", lambda include_gpu=False: snapshot)
try:
await runner._emit_heartbeat(store) # pyright: ignore[reportPrivateUsage]
finally:
teardown_runner(runner)
assert store.worker_updates == []
@pytest.mark.asyncio
async def test_emit_heartbeat_skips_when_store_update_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
snapshot = {"cpu_pct": 8.8}
async def immediate_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
monkeypatch.setattr("agentlightning.runner.agent.asyncio.to_thread", immediate_to_thread)
monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", lambda include_gpu=False: snapshot)
runner, store = await setup_heartbeat_runner(heartbeat_interval=0.05)
interval = runner._heartbeat_interval # pyright: ignore[reportPrivateUsage]
original_update_worker = store.update_worker
async def slow_update_worker(*args: Any, **kwargs: Any) -> Worker:
await asyncio.sleep(interval + 0.05)
return await original_update_worker(*args, **kwargs)
monkeypatch.setattr(store, "update_worker", slow_update_worker)
try:
await runner._emit_heartbeat(store) # pyright: ignore[reportPrivateUsage]
finally:
teardown_runner(runner)
assert store.worker_updates == []
@pytest.mark.asyncio
async def test_heartbeat_loop_runs_until_stopped(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that heartbeat loop runs with the default launch mode until stopped."""
snapshot = {"timestamp": 1234567890}
monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", lambda include_gpu=False: snapshot)
runner, store = await setup_heartbeat_runner(heartbeat_interval=0.05)
stop_heartbeat = runner._start_heartbeat_loop(store) # pyright: ignore[reportPrivateUsage]
assert stop_heartbeat is not None
try:
await asyncio.sleep(0.12)
finally:
await stop_heartbeat()
update_count = len(store.worker_updates)
assert update_count >= 1
assert all(stats == snapshot for _, stats in store.worker_updates if stats is not None)
await asyncio.sleep(0.06)
assert len(store.worker_updates) == update_count
teardown_runner(runner)
@pytest.mark.asyncio
async def test_asyncio_heartbeat_loop_runs_until_stopped(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that asyncio heartbeat loop runs until stopped (explicit asyncio mode test)."""
snapshot = {"timestamp": 1234567890}
monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", lambda include_gpu=False: snapshot)
runner, store = await setup_heartbeat_runner(heartbeat_interval=0.05, heartbeat_launch_mode="asyncio")
stop_heartbeat = runner._start_heartbeat_loop(store) # pyright: ignore[reportPrivateUsage]
assert stop_heartbeat is not None
try:
await asyncio.sleep(0.12)
finally:
await stop_heartbeat()
update_count = len(store.worker_updates)
assert update_count >= 1
assert all(stats == snapshot for _, stats in store.worker_updates if stats is not None)
await asyncio.sleep(0.06)
assert len(store.worker_updates) == update_count
teardown_runner(runner)
@pytest.mark.asyncio
async def test_thread_heartbeat_loop_runs_until_stopped(monkeypatch: pytest.MonkeyPatch) -> None:
snapshot = {"timestamp": time.time()}
include_flags: List[bool] = []
def fake_system_snapshot(include_gpu: bool = False) -> Dict[str, Any]:
include_flags.append(include_gpu)
return snapshot
monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", fake_system_snapshot)
runner, store = await setup_heartbeat_runner(
heartbeat_interval=0.05,
heartbeat_launch_mode="thread",
heartbeat_include_gpu=True,
)
stop_heartbeat = runner._start_heartbeat_loop(store) # pyright: ignore[reportPrivateUsage]
assert stop_heartbeat is not None
try:
await asyncio.sleep(0.3)
finally:
await stop_heartbeat()
teardown_runner(runner)
assert len(store.worker_updates) >= 1
assert all(stats == snapshot for _, stats in store.worker_updates if stats is not None)
assert include_flags and all(include_flags)
@pytest.mark.asyncio
async def test_thread_heartbeat_handles_producer_exception(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that thread mode producer handles exceptions and continues running."""
call_count = 0
def fake_system_snapshot(_include_gpu: bool = False) -> Dict[str, Any]:
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("Simulated snapshot failure")
return {"cpu_pct": 25.0}
monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", fake_system_snapshot)
caplog.set_level(logging.WARNING)
runner, store = await setup_heartbeat_runner(
heartbeat_interval=0.02,
heartbeat_launch_mode="thread",
)
runner._interval_jitter = 0.01 # pyright: ignore[reportPrivateUsage]
stop_heartbeat = runner._start_heartbeat_loop(store) # pyright: ignore[reportPrivateUsage]
assert stop_heartbeat is not None
try:
# Wait long enough for at least 2 cycles (2 * (interval + jitter) + buffer)
await asyncio.sleep(0.15)
finally:
await stop_heartbeat()
teardown_runner(runner)
# Verify the producer logged the exception but continued
assert "system_snapshot failed" in caplog.text
assert call_count >= 2
@pytest.mark.asyncio
async def test_thread_heartbeat_handles_consumer_exception(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that thread mode consumer handles store update exceptions and continues."""
snapshot = {"cpu_pct": 33.0}
monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", lambda include_gpu=False: snapshot)
runner, store = await setup_heartbeat_runner(
heartbeat_interval=0.02,
heartbeat_launch_mode="thread",
)
runner._interval_jitter = 0.01 # pyright: ignore[reportPrivateUsage]
update_count = 0
original_update_worker = store.update_worker
async def failing_update_worker(*args: Any, **kwargs: Any) -> Worker:
nonlocal update_count
update_count += 1
if update_count == 1:
raise RuntimeError("Simulated store failure")
return await original_update_worker(*args, **kwargs)
monkeypatch.setattr(store, "update_worker", failing_update_worker)
caplog.set_level(logging.WARNING)
stop_heartbeat = runner._start_heartbeat_loop(store) # pyright: ignore[reportPrivateUsage]
assert stop_heartbeat is not None
try:
# Wait long enough for at least 2 cycles
await asyncio.sleep(0.15)
finally:
await stop_heartbeat()
teardown_runner(runner)
# Verify the consumer logged the exception but continued
assert "update failed" in caplog.text
assert update_count >= 2
@pytest.mark.asyncio
async def test_thread_heartbeat_waits_for_first_snapshot(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that thread mode consumer skips update when no snapshot is available yet."""
snapshot_ready = False
def fake_system_snapshot(_include_gpu: bool = False) -> Dict[str, Any]:
nonlocal snapshot_ready
if not snapshot_ready:
time.sleep(0.1) # Simulate slow first snapshot
snapshot_ready = True
return {"cpu_pct": 42.0}
monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", fake_system_snapshot)
caplog.set_level(logging.DEBUG)
runner, store = await setup_heartbeat_runner(
heartbeat_interval=0.05,
heartbeat_launch_mode="thread",
)
stop_heartbeat = runner._start_heartbeat_loop(store) # pyright: ignore[reportPrivateUsage]
assert stop_heartbeat is not None
try:
await asyncio.sleep(0.25)
finally:
await stop_heartbeat()
teardown_runner(runner)
# Verify the consumer logged that no snapshot was available initially
assert "no snapshot yet" in caplog.text
@pytest.mark.asyncio
async def test_thread_heartbeat_logs_stale_snapshot_only_once(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that stale snapshot warning is only logged once per stale snapshot."""
call_count = 0
def fake_system_snapshot(_include_gpu: bool = False) -> Dict[str, Any]:
nonlocal call_count
call_count += 1
# Only create one snapshot, then hang
if call_count == 1:
return {"cpu_pct": 50.0}
# Simulate hung producer
time.sleep(10)
return {"cpu_pct": 99.0}
monkeypatch.setattr("agentlightning.runner.agent.system_snapshot", fake_system_snapshot)
caplog.set_level(logging.WARNING)
runner, store = await setup_heartbeat_runner(
heartbeat_interval=0.01,
heartbeat_launch_mode="thread",
)
runner._interval_jitter = 0.01 # pyright: ignore[reportPrivateUsage]
stop_heartbeat = runner._start_heartbeat_loop(store) # pyright: ignore[reportPrivateUsage]
assert stop_heartbeat is not None
try:
# Wait long enough for the single snapshot to become stale
# and for multiple consumer iterations to check it
# stale_after = 0.01 + 0.01 + 1.0 = 1.02s
await asyncio.sleep(1.3)
finally:
await stop_heartbeat()
teardown_runner(runner)
# Count how many times the stale warning appears
stale_warnings = caplog.text.count("snapshot stale")
# Should only warn once, even though consumer checked multiple times
assert stale_warnings == 1
@pytest.mark.asyncio
async def test_heartbeat_disabled_when_interval_zero() -> None:
"""Test that heartbeat loop is not started when interval is 0 or negative."""
runner, store = await setup_heartbeat_runner(heartbeat_interval=0.0)
try:
stop_heartbeat = runner._start_heartbeat_loop(store) # pyright: ignore[reportPrivateUsage]
assert stop_heartbeat is None
finally:
teardown_runner(runner)
@pytest.mark.asyncio
async def test_heartbeat_disabled_when_no_worker_id(caplog: pytest.LogCaptureFixture) -> None:
"""Test that heartbeat loop returns None when worker_id is not set."""
store = RecordingStore()
runner = LitAgentRunner[Any](
tracer=DummyTracer(),
heartbeat_interval=0.05,
)
agent = HeartbeatAgent()
runner.init(agent)
# Note: NOT calling init_worker, so worker_id remains None
caplog.set_level(logging.WARNING)
stop_heartbeat = runner._start_heartbeat_loop(store) # pyright: ignore[reportPrivateUsage]
assert stop_heartbeat is None
assert "Cannot start heartbeat loop without worker_id" in caplog.text
teardown_runner(runner)
@pytest.mark.asyncio
async def test_emit_heartbeat_propagates_cancelled_error() -> None:
"""Test that CancelledError is properly propagated in _emit_heartbeat."""
runner, store = await setup_heartbeat_runner(heartbeat_interval=0.1)
async def cancelling_to_thread(_func: Any, *_args: Any, **_kwargs: Any) -> Any:
raise asyncio.CancelledError()
original_to_thread = asyncio.to_thread
try:
asyncio.to_thread = cancelling_to_thread # type: ignore
with pytest.raises(asyncio.CancelledError):
await runner._emit_heartbeat(store) # pyright: ignore[reportPrivateUsage]
finally:
asyncio.to_thread = original_to_thread # type: ignore
teardown_runner(runner)
@pytest.mark.asyncio
async def test_asyncio_heartbeat_continues_after_exception(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that asyncio heartbeat loop continues running after exceptions."""
call_count = 0
async def failing_emit_heartbeat(_self: Any, _store: Any) -> None:
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("Simulated heartbeat failure")
monkeypatch.setattr(LitAgentRunner, "_emit_heartbeat", failing_emit_heartbeat)
caplog.set_level(logging.ERROR)
runner, store = await setup_heartbeat_runner(
heartbeat_interval=0.02,
heartbeat_launch_mode="asyncio",
)
runner._interval_jitter = 0.01 # pyright: ignore[reportPrivateUsage]
stop_heartbeat = runner._start_heartbeat_loop(store) # pyright: ignore[reportPrivateUsage]
assert stop_heartbeat is not None
try:
# Wait long enough for at least 2 cycles (2 * (interval + jitter) + buffer)
await asyncio.sleep(0.15)
finally:
await stop_heartbeat()
teardown_runner(runner)
# Verify the loop logged the exception but continued
assert "Heartbeat failed" in caplog.text
assert call_count >= 2