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
250 lines
8.5 KiB
Python
250 lines
8.5 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
import asyncio
|
|
import logging
|
|
from contextlib import suppress
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, List, Sequence
|
|
|
|
import pytest
|
|
|
|
from agentlightning.adapter import TraceAdapter
|
|
from agentlightning.algorithm import Baseline
|
|
from agentlightning.store.memory import InMemoryLightningStore
|
|
from agentlightning.types import (
|
|
LLM,
|
|
NamedResources,
|
|
OtelResource,
|
|
Span,
|
|
SpanContext,
|
|
TraceStatus,
|
|
)
|
|
|
|
LOGGER_NAME = "agentlightning.algorithm.fast"
|
|
|
|
|
|
class _AdapterStub(TraceAdapter[Dict[str, Any]]):
|
|
def adapt(self, source: Sequence[Span], /) -> Dict[str, Any]:
|
|
return {
|
|
"count": len(source),
|
|
"attempt_ids": sorted({span.attempt_id for span in source}),
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class _RolloutArtifacts:
|
|
rollout_id: str
|
|
attempt_id: str
|
|
attempt_sequence: int
|
|
span: Span
|
|
|
|
|
|
def _make_resources() -> NamedResources:
|
|
return {
|
|
"main_llm": LLM(endpoint="http://localhost", model="test-model"),
|
|
}
|
|
|
|
|
|
def _build_span(rollout_id: str, attempt_id: str, *, sequence_id: int, index: int) -> Span:
|
|
trace_hex = f"{index:032x}"
|
|
span_hex = f"{index:016x}"
|
|
# Minimal span that passes validation and keeps log output predictable.
|
|
return Span(
|
|
rollout_id=rollout_id,
|
|
attempt_id=attempt_id,
|
|
sequence_id=sequence_id,
|
|
trace_id=trace_hex,
|
|
span_id=span_hex,
|
|
parent_id=None,
|
|
name="test-span",
|
|
status=TraceStatus(status_code="OK"),
|
|
attributes={"stage": "collect"},
|
|
events=[],
|
|
links=[],
|
|
start_time=None,
|
|
end_time=None,
|
|
context=SpanContext(trace_id=trace_hex, span_id=span_hex, is_remote=False, trace_state={}),
|
|
parent=None,
|
|
resource=OtelResource(attributes={}, schema_url=""),
|
|
)
|
|
|
|
|
|
async def _mock_runner(
|
|
*,
|
|
store: InMemoryLightningStore,
|
|
expected: int,
|
|
artifacts: List[_RolloutArtifacts],
|
|
) -> None:
|
|
"""Simulate a runner consuming rollouts, adding spans, and marking them complete."""
|
|
processed = 0
|
|
while processed < expected:
|
|
attempted = await store.dequeue_rollout()
|
|
if attempted is None:
|
|
await asyncio.sleep(0.001)
|
|
continue
|
|
|
|
attempt = attempted.attempt
|
|
rollout_id = attempted.rollout_id
|
|
await store.update_attempt(
|
|
rollout_id,
|
|
attempt.attempt_id,
|
|
status="running",
|
|
worker_id="runner-1",
|
|
)
|
|
|
|
span = _build_span(rollout_id, attempt.attempt_id, sequence_id=1, index=processed + 1)
|
|
await store.add_span(span)
|
|
await store.update_attempt(rollout_id, attempt.attempt_id, status="succeeded")
|
|
await store.update_rollout(rollout_id, status="succeeded")
|
|
|
|
artifacts.append(
|
|
_RolloutArtifacts(
|
|
rollout_id=rollout_id,
|
|
attempt_id=attempt.attempt_id,
|
|
attempt_sequence=attempt.sequence_id,
|
|
span=span,
|
|
)
|
|
)
|
|
processed += 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mock_algorithm_collects_rollout_logs(caplog: pytest.LogCaptureFixture) -> None:
|
|
store = InMemoryLightningStore()
|
|
await store.update_resources("default", _make_resources())
|
|
algorithm = Baseline(polling_interval=0.01, span_verbosity="key_values")
|
|
algorithm.set_store(store)
|
|
adapter = _AdapterStub()
|
|
algorithm.set_adapter(adapter)
|
|
|
|
caplog.set_level(logging.INFO, logger=LOGGER_NAME)
|
|
|
|
train_dataset = ["train-sample", "validation-sample"]
|
|
expected_rollouts = len(train_dataset)
|
|
artifacts: List[_RolloutArtifacts] = []
|
|
|
|
runner_task = asyncio.create_task(_mock_runner(store=store, expected=expected_rollouts, artifacts=artifacts))
|
|
try:
|
|
await algorithm.run(train_dataset=train_dataset)
|
|
await asyncio.wait_for(runner_task, timeout=2)
|
|
finally:
|
|
if not runner_task.done():
|
|
runner_task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await runner_task
|
|
|
|
log_messages = [record.getMessage() for record in caplog.records if record.name == LOGGER_NAME]
|
|
|
|
# Ensure final status, attempt details, span details, and adapter output are logged per rollout.
|
|
for entry in artifacts:
|
|
attempt_summary = (
|
|
f"[Rollout {entry.rollout_id} | Attempt {entry.attempt_sequence}] "
|
|
f"ID: {entry.attempt_id}. Status: succeeded. Worker: runner-1"
|
|
)
|
|
assert attempt_summary in log_messages
|
|
|
|
span_prefix = (
|
|
f"[Rollout {entry.rollout_id} | Attempt {entry.attempt_id} | Span {entry.span.span_id}] "
|
|
f"#{entry.span.sequence_id} ({entry.span.name}) "
|
|
)
|
|
assert any(msg.startswith(span_prefix + "From") for msg in log_messages)
|
|
assert any(f"Attributes: {entry.span.attributes}" in msg for msg in log_messages)
|
|
assert any(
|
|
msg.startswith(f"[Rollout {entry.rollout_id}] Finished with status succeeded") for msg in log_messages
|
|
)
|
|
assert any(
|
|
msg.startswith(f"[Rollout {entry.rollout_id}] Adapted data: ")
|
|
and "'count': 1" in msg
|
|
and entry.attempt_id in msg
|
|
for msg in log_messages
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_baseline_does_not_skip_samples_when_queue_full() -> None:
|
|
"""Test that Baseline waits and retries when queue is full instead of skipping samples.
|
|
|
|
This is a regression test for a bug where samples would be skipped when the queue
|
|
exceeded max_queue_length. The fix wraps the queue check in a while loop to ensure
|
|
all samples are eventually processed.
|
|
"""
|
|
store = InMemoryLightningStore()
|
|
await store.update_resources("default", _make_resources())
|
|
|
|
# Use a small max_queue_length and fast polling to test queue full behavior
|
|
algorithm = Baseline(polling_interval=0.01, max_queue_length=1)
|
|
algorithm.set_store(store)
|
|
|
|
# Create a dataset with 5 samples
|
|
train_dataset = [f"sample-{i}" for i in range(5)]
|
|
expected_rollouts = len(train_dataset)
|
|
|
|
# Track which samples were enqueued
|
|
enqueued_samples: List[Any] = []
|
|
artifacts: List[_RolloutArtifacts] = []
|
|
|
|
async def _slow_runner() -> None:
|
|
"""A slow runner that creates backpressure by processing rollouts with delays."""
|
|
processed = 0
|
|
while processed < expected_rollouts:
|
|
attempted = await store.dequeue_rollout()
|
|
if attempted is None:
|
|
await asyncio.sleep(0.01)
|
|
continue
|
|
|
|
attempt = attempted.attempt
|
|
rollout_id = attempted.rollout_id
|
|
rollout = await store.get_rollout_by_id(rollout_id)
|
|
|
|
# Track the sample that was enqueued
|
|
if rollout:
|
|
enqueued_samples.append(rollout.input)
|
|
|
|
await store.update_attempt(
|
|
rollout_id,
|
|
attempt.attempt_id,
|
|
status="running",
|
|
worker_id="slow-runner",
|
|
)
|
|
|
|
# Add a delay to create backpressure and cause queue to fill up
|
|
await asyncio.sleep(0.05)
|
|
|
|
span = _build_span(rollout_id, attempt.attempt_id, sequence_id=1, index=processed + 1)
|
|
await store.add_span(span)
|
|
await store.update_attempt(rollout_id, attempt.attempt_id, status="succeeded")
|
|
await store.update_rollout(rollout_id, status="succeeded")
|
|
|
|
artifacts.append(
|
|
_RolloutArtifacts(
|
|
rollout_id=rollout_id,
|
|
attempt_id=attempt.attempt_id,
|
|
attempt_sequence=attempt.sequence_id,
|
|
span=span,
|
|
)
|
|
)
|
|
processed += 1
|
|
|
|
runner_task = asyncio.create_task(_slow_runner())
|
|
try:
|
|
await algorithm.run(train_dataset=train_dataset)
|
|
await asyncio.wait_for(runner_task, timeout=5)
|
|
finally:
|
|
if not runner_task.done():
|
|
runner_task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await runner_task
|
|
|
|
# Verify that ALL samples were enqueued and processed (no samples skipped)
|
|
assert (
|
|
len(enqueued_samples) == expected_rollouts
|
|
), f"Expected {expected_rollouts} samples to be enqueued, but got {len(enqueued_samples)}"
|
|
assert (
|
|
len(artifacts) == expected_rollouts
|
|
), f"Expected {expected_rollouts} rollouts to be processed, but got {len(artifacts)}"
|
|
|
|
# Verify that the enqueued samples match the dataset (in order)
|
|
assert (
|
|
enqueued_samples == train_dataset
|
|
), f"Enqueued samples {enqueued_samples} do not match dataset {train_dataset}"
|