chore: import upstream snapshot with attribution
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
@@ -0,0 +1,942 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
from typing import Any, Dict, Iterator, List, Literal, Optional, Sequence, Tuple, cast
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
import agentlightning.algorithm.apo.apo as apo_module
|
||||
from agentlightning.adapter import TraceAdapter
|
||||
from agentlightning.adapter.messages import TraceToMessages
|
||||
from agentlightning.algorithm.apo.apo import APO, RolloutResultForAPO, VersionedPromptTemplate, batch_iter_over_dataset
|
||||
from agentlightning.semconv import AGL_ANNOTATION
|
||||
from agentlightning.types import (
|
||||
Dataset,
|
||||
NamedResources,
|
||||
)
|
||||
from agentlightning.types import OtelResource as SpanResource
|
||||
from agentlightning.types import (
|
||||
PromptTemplate,
|
||||
Rollout,
|
||||
Span,
|
||||
SpanContext,
|
||||
TraceStatus,
|
||||
)
|
||||
|
||||
|
||||
class DummyTraceMessagesAdapter(TraceToMessages):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.seen_spans: Sequence[Span] | None = None
|
||||
|
||||
def adapt(self, source: Sequence[Span], /) -> List[Dict[str, Any]]: # type: ignore[override]
|
||||
self.seen_spans = list(source)
|
||||
return [dict(payload="converted")]
|
||||
|
||||
|
||||
class WrongAdapter(TraceAdapter[List[int]]):
|
||||
def adapt(self, source: Sequence[Span], /) -> List[int]:
|
||||
return [len(source)]
|
||||
|
||||
|
||||
class DummyStore:
|
||||
def __init__(self) -> None:
|
||||
self.update_resources_calls: List[Tuple[str, NamedResources]] = []
|
||||
self.enqueue_calls: List[Dict[str, Any]] = []
|
||||
self.wait_calls: List[Dict[str, Any]] = []
|
||||
self.wait_results_queue: List[List[Rollout]] = []
|
||||
self.query_spans_map: Dict[str, List[Span]] = {}
|
||||
self._counter = 0
|
||||
|
||||
async def update_resources(self, resources_id: str, resources: NamedResources) -> Mock:
|
||||
self.update_resources_calls.append((resources_id, resources))
|
||||
update_mock = Mock()
|
||||
update_mock.resources_id = resources_id
|
||||
return update_mock
|
||||
|
||||
async def enqueue_rollout(
|
||||
self,
|
||||
*,
|
||||
input: Dict[str, Any],
|
||||
mode: str,
|
||||
resources_id: Optional[str] = None,
|
||||
) -> Mock:
|
||||
rollout_id = f"rollout-{self._counter}"
|
||||
self._counter += 1
|
||||
self.enqueue_calls.append(
|
||||
{"rollout_id": rollout_id, "input": input, "mode": mode, "resources_id": resources_id}
|
||||
)
|
||||
result = Mock()
|
||||
result.rollout_id = rollout_id
|
||||
return result
|
||||
|
||||
async def wait_for_rollouts(self, rollout_ids: Sequence[str], timeout: float) -> List[Rollout]:
|
||||
self.wait_calls.append({"rollout_ids": tuple(rollout_ids), "timeout": timeout})
|
||||
if self.wait_results_queue:
|
||||
return self.wait_results_queue.pop(0)
|
||||
return []
|
||||
|
||||
async def query_spans(
|
||||
self,
|
||||
rollout_id: str,
|
||||
attempt_id: str | Literal["latest"] | None = None,
|
||||
**_: Any,
|
||||
) -> List[Span]:
|
||||
return list(self.query_spans_map.get(rollout_id, []))
|
||||
|
||||
|
||||
def make_completion(content: str | None) -> Mock:
|
||||
"""Create a mock OpenAI completion response."""
|
||||
message_mock = Mock()
|
||||
message_mock.content = content
|
||||
choice_mock = Mock()
|
||||
choice_mock.message = message_mock
|
||||
completion_mock = Mock()
|
||||
completion_mock.choices = [choice_mock]
|
||||
return completion_mock
|
||||
|
||||
|
||||
def make_openai_client(create_mock: AsyncMock) -> Mock:
|
||||
"""Create a mock AsyncOpenAI client with the given create method."""
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
completions = Mock()
|
||||
completions.create = create_mock
|
||||
chat = Mock()
|
||||
chat.completions = completions
|
||||
client.chat = chat
|
||||
return client
|
||||
|
||||
|
||||
def make_reward_span(rollout_id: str, attempt_id: str, reward: float, sequence_id: int) -> Span:
|
||||
hex_id = f"{sequence_id:032x}"
|
||||
span_hex = f"{sequence_id:016x}"
|
||||
return Span(
|
||||
rollout_id=rollout_id,
|
||||
attempt_id=attempt_id,
|
||||
sequence_id=sequence_id,
|
||||
trace_id=hex_id,
|
||||
span_id=span_hex,
|
||||
parent_id=None,
|
||||
name=AGL_ANNOTATION,
|
||||
status=TraceStatus(status_code="OK"),
|
||||
attributes={"reward": reward},
|
||||
events=[],
|
||||
links=[],
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
context=SpanContext(trace_id=hex_id, span_id=span_hex, is_remote=False, trace_state={}),
|
||||
parent=None,
|
||||
resource=SpanResource(attributes={}, schema_url=""),
|
||||
)
|
||||
|
||||
|
||||
def test_batch_iter_over_dataset_returns_full_dataset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
dataset = [{"id": idx} for idx in range(3)]
|
||||
monkeypatch.setattr(apo_module.random, "shuffle", lambda seq: None) # type: ignore
|
||||
|
||||
iterator = batch_iter_over_dataset(cast(Dataset[Any], dataset), batch_size=5)
|
||||
|
||||
first_batch = next(iterator)
|
||||
second_batch = next(iterator)
|
||||
|
||||
assert len(first_batch) == len(dataset)
|
||||
assert len(second_batch) == len(dataset)
|
||||
assert {item["id"] for item in first_batch} == {0, 1, 2}
|
||||
|
||||
|
||||
def test_batch_iter_over_dataset_cycles_batches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
dataset = [{"id": idx} for idx in range(4)]
|
||||
|
||||
def fake_shuffle(seq: List[int]) -> None:
|
||||
seq.reverse()
|
||||
|
||||
monkeypatch.setattr(apo_module.random, "shuffle", fake_shuffle)
|
||||
|
||||
iterator = batch_iter_over_dataset(cast(Dataset[Any], dataset), batch_size=2)
|
||||
|
||||
batch_one = next(iterator)
|
||||
batch_two = next(iterator)
|
||||
batch_three = next(iterator)
|
||||
|
||||
assert len(batch_one) == 2
|
||||
assert len(batch_two) == 2
|
||||
assert {item["id"] for item in batch_three}.issubset({item["id"] for item in batch_one + batch_two}) # type: ignore
|
||||
|
||||
|
||||
def test_apo_init_sets_configuration() -> None:
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
|
||||
apo = APO[Any](
|
||||
client,
|
||||
gradient_model="g-model",
|
||||
apply_edit_model="a-model",
|
||||
diversity_temperature=0.7,
|
||||
gradient_batch_size=3,
|
||||
val_batch_size=5,
|
||||
beam_width=2,
|
||||
branch_factor=3,
|
||||
beam_rounds=4,
|
||||
rollout_batch_timeout=42.0,
|
||||
run_initial_validation=False,
|
||||
)
|
||||
|
||||
assert apo.async_openai_client is client
|
||||
assert apo.gradient_model == "g-model"
|
||||
assert apo.apply_edit_model == "a-model"
|
||||
assert apo.diversity_temperature == 0.7
|
||||
assert apo.gradient_batch_size == 3
|
||||
assert apo.val_batch_size == 5
|
||||
assert apo.beam_width == 2
|
||||
assert apo.branch_factor == 3
|
||||
assert apo.beam_rounds == 4
|
||||
assert apo.rollout_batch_timeout == 42.0
|
||||
assert apo.run_initial_validation is False
|
||||
assert apo._history_best_prompt is None
|
||||
assert apo._history_best_score == float("-inf")
|
||||
|
||||
|
||||
def test_get_seed_prompt_template_returns_prompt() -> None:
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
apo = APO[Any](client)
|
||||
prompt = PromptTemplate(template="Seed: {x}", engine="f-string")
|
||||
resources: NamedResources = {
|
||||
"seed": prompt,
|
||||
"other": PromptTemplate(template="Other", engine="f-string"),
|
||||
}
|
||||
apo.set_initial_resources(resources)
|
||||
|
||||
resource_name, seed_prompt = apo.get_seed_prompt_template()
|
||||
|
||||
assert resource_name == "seed"
|
||||
assert seed_prompt is prompt
|
||||
|
||||
|
||||
def test_get_seed_prompt_template_requires_resources() -> None:
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
apo = APO[Any](client)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
apo.get_seed_prompt_template()
|
||||
|
||||
|
||||
def test_get_seed_prompt_template_requires_prompt_resource() -> None:
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
apo = APO[Any](client)
|
||||
apo.set_initial_resources({})
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
apo.get_seed_prompt_template()
|
||||
|
||||
|
||||
def test_get_adapter_returns_trace_messages_adapter() -> None:
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
apo = APO[Any](client)
|
||||
adapter = DummyTraceMessagesAdapter()
|
||||
apo.set_adapter(adapter)
|
||||
|
||||
assert apo.get_adapter() is adapter
|
||||
|
||||
|
||||
def test_get_adapter_requires_trace_messages_adapter() -> None:
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
apo = APO[Any](client)
|
||||
apo.set_adapter(WrongAdapter())
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
apo.get_adapter()
|
||||
|
||||
|
||||
def test_get_best_prompt_requires_history() -> None:
|
||||
apo = APO[Any](Mock(spec=AsyncOpenAI))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
apo.get_best_prompt()
|
||||
|
||||
|
||||
def test_get_best_prompt_returns_prompt() -> None:
|
||||
apo = APO[Any](Mock(spec=AsyncOpenAI))
|
||||
prompt = PromptTemplate(template="Best", engine="f-string")
|
||||
apo._history_best_prompt = prompt
|
||||
|
||||
assert apo.get_best_prompt() is prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_textual_gradient_samples_batch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
create_mock = AsyncMock(return_value=make_completion("critique"))
|
||||
client = make_openai_client(create_mock)
|
||||
apo = APO[Any](client, gradient_model="test-gradient-model", gradient_batch_size=2, diversity_temperature=0.8)
|
||||
versioned_prompt = apo._create_versioned_prompt(PromptTemplate(template="prompt", engine="f-string"))
|
||||
rollouts: List[RolloutResultForAPO] = [
|
||||
RolloutResultForAPO(status="succeeded", final_reward=float(i), spans=[], messages=[]) for i in range(3)
|
||||
]
|
||||
|
||||
sample_mock = Mock(return_value=rollouts[:2])
|
||||
monkeypatch.setattr(apo_module.random, "sample", sample_mock)
|
||||
monkeypatch.setattr(apo_module.random, "choice", lambda seq: seq[0]) # type: ignore
|
||||
|
||||
result = await apo.compute_textual_gradient(versioned_prompt, rollouts)
|
||||
|
||||
assert result == "critique"
|
||||
sample_mock.assert_called_once_with(rollouts, 2)
|
||||
# Verify OpenAI call was made with correct parameters
|
||||
create_mock.assert_awaited_once()
|
||||
call_kwargs = create_mock.await_args.kwargs # type: ignore
|
||||
assert call_kwargs["model"] == "test-gradient-model"
|
||||
assert call_kwargs["temperature"] == 0.8
|
||||
assert len(call_kwargs["messages"]) == 1
|
||||
assert call_kwargs["messages"][0]["role"] == "user"
|
||||
assert call_kwargs["messages"][0]["content"].startswith("You optimize a prompt template.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_textual_gradient_uses_all_rollouts_when_insufficient(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
create_mock = AsyncMock(return_value=make_completion("critique"))
|
||||
client = make_openai_client(create_mock)
|
||||
apo = APO[Any](client, gradient_batch_size=3)
|
||||
versioned_prompt = apo._create_versioned_prompt(PromptTemplate(template="prompt", engine="f-string"))
|
||||
rollouts: List[RolloutResultForAPO] = [
|
||||
RolloutResultForAPO(status="succeeded", final_reward=1.0, spans=[], messages=[])
|
||||
]
|
||||
|
||||
sample_mock = Mock(side_effect=AssertionError("sample should not be called"))
|
||||
monkeypatch.setattr(apo_module.random, "sample", sample_mock)
|
||||
monkeypatch.setattr(apo_module.random, "choice", lambda seq: seq[0]) # type: ignore
|
||||
|
||||
result = await apo.compute_textual_gradient(versioned_prompt, rollouts)
|
||||
|
||||
assert result == "critique"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_textual_gradient_and_apply_edit_returns_new_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Use two separate mocks for gradient and edit calls
|
||||
gradient_mock = AsyncMock(return_value=make_completion("critique text"))
|
||||
edit_mock = AsyncMock(return_value=make_completion("new prompt"))
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def create_side_effect(*args: Any, **kwargs: Any) -> Mock:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return gradient_mock.return_value if call_count == 1 else edit_mock.return_value
|
||||
|
||||
create_mock = AsyncMock(side_effect=create_side_effect)
|
||||
client = make_openai_client(create_mock)
|
||||
apo = APO[Any](client, gradient_model="grad-model", apply_edit_model="edit-model", diversity_temperature=0.9)
|
||||
|
||||
monkeypatch.setattr(apo_module.random, "choice", lambda seq: seq[0]) # type: ignore
|
||||
monkeypatch.setattr(apo_module.random, "sample", lambda population, k: list(population)[:k]) # type: ignore
|
||||
|
||||
poml_calls: List[Dict[str, Any]] = []
|
||||
|
||||
def poml_side_effect(template: Any, context: Dict[str, Any], format: str) -> Dict[str, Any]:
|
||||
poml_calls.append({"template": template, "context": context, "format": format})
|
||||
return {"messages": [{"role": "user", "content": "msg"}]}
|
||||
|
||||
monkeypatch.setattr(apo_module.poml, "poml", poml_side_effect)
|
||||
|
||||
versioned_prompt = apo._create_versioned_prompt(PromptTemplate(template="old prompt", engine="f-string"))
|
||||
rollouts: List[RolloutResultForAPO] = [
|
||||
RolloutResultForAPO(status="succeeded", final_reward=1.0, spans=[], messages=[])
|
||||
]
|
||||
|
||||
result = await apo.textual_gradient_and_apply_edit(versioned_prompt, rollouts)
|
||||
|
||||
assert result == "new prompt"
|
||||
assert create_mock.await_count == 2
|
||||
|
||||
# Verify gradient computation call
|
||||
first_call = create_mock.await_args_list[0].kwargs
|
||||
assert first_call["model"] == "grad-model"
|
||||
assert first_call["temperature"] == 0.9
|
||||
|
||||
# Verify edit application call
|
||||
second_call = create_mock.await_args_list[1].kwargs
|
||||
assert second_call["model"] == "edit-model"
|
||||
assert second_call["temperature"] == 0.9
|
||||
|
||||
# Verify critique was passed to edit context
|
||||
assert len(poml_calls) == 2
|
||||
assert poml_calls[1]["context"]["critique"] == "critique text"
|
||||
assert poml_calls[1]["context"]["prompt_template"] == "old prompt"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_textual_gradient_and_apply_edit_returns_original_if_no_critique(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Mock OpenAI to return None content
|
||||
create_mock = AsyncMock(return_value=make_completion(None))
|
||||
client = make_openai_client(create_mock)
|
||||
apo = APO[Any](client)
|
||||
|
||||
monkeypatch.setattr(apo_module.random, "choice", lambda seq: seq[0]) # type: ignore
|
||||
monkeypatch.setattr(apo_module.random, "sample", lambda population, k: list(population)[:k]) # type: ignore
|
||||
|
||||
versioned_prompt = apo._create_versioned_prompt(PromptTemplate(template="old prompt", engine="f-string"))
|
||||
rollouts: List[RolloutResultForAPO] = [
|
||||
RolloutResultForAPO(status="succeeded", final_reward=1.0, spans=[], messages=[])
|
||||
]
|
||||
|
||||
result = await apo.textual_gradient_and_apply_edit(versioned_prompt, rollouts)
|
||||
|
||||
# Should return original prompt when gradient computation fails
|
||||
assert result == "old prompt"
|
||||
# Verify gradient computation was attempted
|
||||
create_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_rollout_results_adapts_spans() -> None:
|
||||
apo = APO[Any](Mock(spec=AsyncOpenAI))
|
||||
store = DummyStore()
|
||||
adapter = DummyTraceMessagesAdapter()
|
||||
apo.set_store(store) # type: ignore
|
||||
apo.set_adapter(adapter)
|
||||
|
||||
rollout = Rollout(
|
||||
rollout_id="r-1",
|
||||
input={"task": "value"},
|
||||
start_time=0.0,
|
||||
status="succeeded",
|
||||
mode="train",
|
||||
)
|
||||
span1 = make_reward_span("r-1", "attempt", 1.0, sequence_id=1)
|
||||
span2 = make_reward_span("r-1", "attempt", 2.0, sequence_id=2)
|
||||
store.query_spans_map["r-1"] = [span1, span2]
|
||||
|
||||
results = await apo.get_rollout_results([rollout])
|
||||
|
||||
assert len(results) == 1
|
||||
# Verify final reward is correctly extracted
|
||||
assert results[0]["final_reward"] == 2.0
|
||||
# Verify status is correctly mapped
|
||||
assert results[0]["status"] == "succeeded"
|
||||
# Verify adapter was called with correct spans
|
||||
assert adapter.seen_spans is not None
|
||||
assert len(adapter.seen_spans) == 2
|
||||
assert adapter.seen_spans[0] == span1
|
||||
assert adapter.seen_spans[1] == span2
|
||||
# Verify messages were converted
|
||||
assert results[0]["messages"] == [{"payload": "converted"}]
|
||||
# Verify spans were serialized
|
||||
assert len(results[0]["spans"]) == 2
|
||||
assert results[0]["spans"][0]["rollout_id"] == "r-1"
|
||||
assert results[0]["spans"][0]["name"] == AGL_ANNOTATION
|
||||
assert results[0]["spans"][0]["attributes"]["reward"] == 1.0
|
||||
assert results[0]["spans"][1]["attributes"]["reward"] == 2.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_prompt_on_batch_runs_rollouts() -> None:
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
apo = APO[Any](client, rollout_batch_timeout=100.0)
|
||||
store = DummyStore()
|
||||
adapter = DummyTraceMessagesAdapter()
|
||||
apo.set_store(store) # type: ignore
|
||||
apo.set_adapter(adapter)
|
||||
|
||||
dataset = [{"task": 1}, {"task": 2}]
|
||||
|
||||
# Set up spans for rollouts
|
||||
store.query_spans_map["rollout-0"] = [make_reward_span("rollout-0", "attempt", 1.0, sequence_id=1)]
|
||||
store.query_spans_map["rollout-1"] = [make_reward_span("rollout-1", "attempt", 0.0, sequence_id=1)]
|
||||
|
||||
store.wait_results_queue.append(
|
||||
[
|
||||
Rollout(
|
||||
rollout_id="rollout-0",
|
||||
input=dataset[0],
|
||||
start_time=0.0,
|
||||
status="succeeded",
|
||||
mode="train",
|
||||
),
|
||||
Rollout(
|
||||
rollout_id="rollout-1",
|
||||
input=dataset[1],
|
||||
start_time=0.0,
|
||||
status="failed",
|
||||
mode="train",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
prompt_template = PromptTemplate(template="test prompt", engine="f-string")
|
||||
versioned_prompt = apo._create_versioned_prompt(prompt_template)
|
||||
|
||||
rollout_results, average = await apo.evaluate_prompt_on_batch(versioned_prompt, "seed", dataset, mode="train")
|
||||
|
||||
# Verify results
|
||||
assert len(rollout_results) == 2
|
||||
assert rollout_results[0]["final_reward"] == 1.0
|
||||
assert rollout_results[0]["status"] == "succeeded"
|
||||
assert rollout_results[1]["final_reward"] == 0.0
|
||||
assert rollout_results[1]["status"] == "failed"
|
||||
assert average == pytest.approx(0.5) # type: ignore
|
||||
|
||||
# Verify resource was added with correct prompt
|
||||
assert len(store.update_resources_calls) == 1
|
||||
resources_id, resources_payload = store.update_resources_calls[0]
|
||||
assert resources_id == versioned_prompt.version
|
||||
assert "seed" in resources_payload
|
||||
added_resource = resources_payload["seed"]
|
||||
assert isinstance(added_resource, PromptTemplate)
|
||||
assert added_resource.template == "test prompt"
|
||||
assert added_resource.engine == "f-string"
|
||||
|
||||
# Verify enqueue was called correctly
|
||||
assert len(store.enqueue_calls) == 2
|
||||
assert store.enqueue_calls[0]["input"] == dataset[0]
|
||||
assert store.enqueue_calls[0]["mode"] == "train"
|
||||
assert store.enqueue_calls[0]["resources_id"] == versioned_prompt.version
|
||||
assert store.enqueue_calls[1]["input"] == dataset[1]
|
||||
assert store.enqueue_calls[1]["mode"] == "train"
|
||||
assert store.enqueue_calls[1]["resources_id"] == versioned_prompt.version
|
||||
|
||||
# Verify wait was called with correct rollout IDs
|
||||
assert len(store.wait_calls) == 1
|
||||
assert set(store.wait_calls[0]["rollout_ids"]) == {"rollout-0", "rollout-1"}
|
||||
assert store.wait_calls[0]["timeout"] == 0.0
|
||||
|
||||
|
||||
def test_initialize_beam_sets_history(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
apo = APO[Any](client, gradient_batch_size=2, val_batch_size=1)
|
||||
prompt = PromptTemplate(template="Seed", engine="f-string")
|
||||
apo.set_initial_resources({"seed": prompt})
|
||||
monkeypatch.setattr(apo_module.random, "shuffle", lambda seq: None) # type: ignore
|
||||
|
||||
train_dataset: Sequence[Dict[str, str]] = [{"x": "1"}, {"x": "2"}]
|
||||
val_dataset: Sequence[Dict[str, str]] = [{"y": "value"}]
|
||||
|
||||
resource_name, seed_prompt, grad_iter, val_iter = apo._initialize_beam(train_dataset, val_dataset) # type: ignore
|
||||
|
||||
assert resource_name == "seed"
|
||||
assert seed_prompt is prompt
|
||||
assert apo._history_best_prompt is prompt
|
||||
assert apo._history_best_score == float("-inf")
|
||||
assert len(next(grad_iter)) == len(train_dataset)
|
||||
assert len(next(val_iter)) == len(val_dataset)
|
||||
|
||||
|
||||
def test_initialize_beam_requires_train_dataset() -> None:
|
||||
apo = APO[Any](Mock(spec=AsyncOpenAI))
|
||||
apo.set_initial_resources({"seed": PromptTemplate(template="Seed", engine="f-string")})
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
apo._initialize_beam(None, []) # type: ignore
|
||||
|
||||
|
||||
def test_initialize_beam_requires_val_dataset() -> None:
|
||||
apo = APO[Any](Mock(spec=AsyncOpenAI))
|
||||
apo.set_initial_resources({"seed": PromptTemplate(template="Seed", engine="f-string")})
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
apo._initialize_beam([], None) # type: ignore
|
||||
|
||||
|
||||
def test_sample_parent_prompts_replicates_when_beam_too_small(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
apo = APO[Any](Mock(spec=AsyncOpenAI), beam_width=3)
|
||||
beam_prompt = apo._create_versioned_prompt(PromptTemplate(template="Seed", engine="f-string"))
|
||||
beam = [beam_prompt]
|
||||
monkeypatch.setattr(apo_module.random, "sample", lambda population, k: (_ for _ in ()).throw(AssertionError())) # type: ignore
|
||||
|
||||
sampled = apo._sample_parent_prompts(beam, round_num=0)
|
||||
|
||||
assert len(sampled) == apo.beam_width
|
||||
assert all(index == 0 and prompt is beam_prompt for index, prompt in sampled)
|
||||
|
||||
|
||||
def test_sample_parent_prompts_uses_random_sample(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
apo = APO[Any](Mock(spec=AsyncOpenAI), beam_width=2)
|
||||
prompt_a = apo._create_versioned_prompt(PromptTemplate(template="A", engine="f-string"))
|
||||
prompt_b = apo._create_versioned_prompt(PromptTemplate(template="B", engine="f-string"))
|
||||
prompt_c = apo._create_versioned_prompt(PromptTemplate(template="C", engine="f-string"))
|
||||
|
||||
monkeypatch.setattr(apo_module.random, "sample", lambda population, k: [0, 2]) # type: ignore
|
||||
|
||||
sampled = apo._sample_parent_prompts([prompt_a, prompt_b, prompt_c], round_num=1)
|
||||
|
||||
assert sampled == [(0, prompt_a), (2, prompt_c)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_candidate_prompts_creates_branch_factor_children() -> None:
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
apo = APO[Any](client, branch_factor=2)
|
||||
store = DummyStore()
|
||||
adapter = DummyTraceMessagesAdapter()
|
||||
apo.set_store(store) # type: ignore
|
||||
apo.set_adapter(adapter)
|
||||
|
||||
parent_prompt = apo._create_versioned_prompt(PromptTemplate(template="Seed", engine="f-string"))
|
||||
grad_batches: Iterator[Sequence[Dict[str, Any]]] = iter(
|
||||
[
|
||||
[{"task": "a"}],
|
||||
[{"task": "b"}],
|
||||
]
|
||||
)
|
||||
|
||||
# Set up rollouts to complete immediately
|
||||
store.query_spans_map["rollout-0"] = [make_reward_span("rollout-0", "attempt", 0.5, sequence_id=1)]
|
||||
store.query_spans_map["rollout-1"] = [make_reward_span("rollout-1", "attempt", 0.6, sequence_id=1)]
|
||||
store.wait_results_queue.extend(
|
||||
[
|
||||
[Rollout(rollout_id="rollout-0", input={"task": "a"}, start_time=0.0, status="succeeded", mode="train")],
|
||||
[Rollout(rollout_id="rollout-1", input={"task": "b"}, start_time=0.0, status="succeeded", mode="train")],
|
||||
]
|
||||
)
|
||||
|
||||
counter = 0
|
||||
|
||||
async def edit_side_effect(
|
||||
current_prompt: VersionedPromptTemplate,
|
||||
rollout: List[RolloutResultForAPO],
|
||||
**_: Any,
|
||||
) -> str:
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
return f"{current_prompt.prompt_template.template}-{counter}"
|
||||
|
||||
apo.textual_gradient_and_apply_edit = AsyncMock(side_effect=edit_side_effect)
|
||||
|
||||
candidates = await apo._generate_candidate_prompts([(0, parent_prompt)], "seed", grad_batches, round_num=0)
|
||||
|
||||
# Verify correct number of candidates generated
|
||||
assert len(candidates) == apo.branch_factor
|
||||
assert {candidate.prompt_template.template for candidate in candidates} == {"Seed-1", "Seed-2"}
|
||||
assert all(candidate.prompt_template.engine == "f-string" for candidate in candidates)
|
||||
|
||||
# Verify evaluate_prompt_on_batch was called for each candidate generation
|
||||
assert len(store.enqueue_calls) == 2
|
||||
assert store.enqueue_calls[0]["input"] == {"task": "a"}
|
||||
assert store.enqueue_calls[1]["input"] == {"task": "b"}
|
||||
assert all(call["mode"] == "train" for call in store.enqueue_calls)
|
||||
assert all(call["resources_id"] == parent_prompt.version for call in store.enqueue_calls)
|
||||
|
||||
# Verify textual_gradient_and_apply_edit was called correct number of times
|
||||
assert apo.textual_gradient_and_apply_edit.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_candidate_prompts_skips_failed_generations() -> None:
|
||||
"""Test that None returns from textual_gradient_and_apply_edit are skipped."""
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
apo = APO[Any](client, branch_factor=3)
|
||||
store = DummyStore()
|
||||
# Keep strong reference to prevent garbage collection since APO uses weakref
|
||||
apo._test_adapter = adapter = DummyTraceMessagesAdapter() # type: ignore
|
||||
apo.set_store(store) # type: ignore
|
||||
apo.set_adapter(adapter)
|
||||
|
||||
parent_prompt = apo._create_versioned_prompt(PromptTemplate(template="Seed", engine="f-string"))
|
||||
grad_batches: Iterator[Sequence[Dict[str, Any]]] = iter([[{"task": f"t{i}"}] for i in range(3)])
|
||||
|
||||
# Set up rollouts
|
||||
for i in range(3):
|
||||
store.query_spans_map[f"rollout-{i}"] = [make_reward_span(f"rollout-{i}", "attempt", 0.5, sequence_id=1)]
|
||||
store.wait_results_queue.append(
|
||||
[
|
||||
Rollout(
|
||||
rollout_id=f"rollout-{i}", input={"task": f"t{i}"}, start_time=0.0, status="succeeded", mode="train"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Mock to return None for second call, valid prompts for others
|
||||
call_count = 0
|
||||
|
||||
async def edit_side_effect(
|
||||
current_prompt: VersionedPromptTemplate,
|
||||
rollout: List[RolloutResultForAPO],
|
||||
**_: Any,
|
||||
) -> Optional[str]:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 2:
|
||||
return None # Simulate failure
|
||||
return f"{current_prompt.prompt_template.template}-{call_count}"
|
||||
|
||||
apo.textual_gradient_and_apply_edit = AsyncMock(side_effect=edit_side_effect)
|
||||
|
||||
candidates = await apo._generate_candidate_prompts([(0, parent_prompt)], "seed", grad_batches, round_num=0)
|
||||
|
||||
# Should only have 2 candidates (one failed)
|
||||
assert len(candidates) == 2
|
||||
assert {candidate.prompt_template.template for candidate in candidates} == {"Seed-1", "Seed-3"}
|
||||
# Verify all three attempts were made
|
||||
assert apo.textual_gradient_and_apply_edit.await_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_and_select_beam_sorts_by_score() -> None:
|
||||
apo = APO[Any](Mock(spec=AsyncOpenAI), beam_width=2)
|
||||
candidates = [
|
||||
apo._create_versioned_prompt(PromptTemplate(template="A", engine="f-string")),
|
||||
apo._create_versioned_prompt(PromptTemplate(template="B", engine="f-string")),
|
||||
apo._create_versioned_prompt(PromptTemplate(template="C", engine="f-string")),
|
||||
]
|
||||
scores = {"A": 1.0, "B": 0.2, "C": 2.0}
|
||||
|
||||
async def evaluate(
|
||||
prompt: VersionedPromptTemplate,
|
||||
resource_name: str,
|
||||
dataset: Sequence[Dict[str, Any]],
|
||||
mode: str,
|
||||
**_: Any,
|
||||
) -> Any:
|
||||
return [], scores[prompt.prompt_template.template]
|
||||
|
||||
apo.evaluate_prompt_on_batch = AsyncMock(side_effect=evaluate) # type: ignore[assignment]
|
||||
|
||||
val_iterator: Iterator[Sequence[Dict[str, Any]]] = iter([[{"task": "val"}]])
|
||||
|
||||
selected = await apo._evaluate_and_select_beam(candidates, "seed", val_iterator, round_num=0)
|
||||
|
||||
assert [prompt.prompt_template.template for prompt in selected] == ["C", "A"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_and_select_beam_raises_on_empty_candidates() -> None:
|
||||
"""Test that ValueError is raised when no candidates remain after evaluation."""
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
apo = APO[Any](client, beam_width=2)
|
||||
# Empty candidate list
|
||||
candidates: List[VersionedPromptTemplate] = []
|
||||
|
||||
val_iterator: Iterator[Sequence[Dict[str, Any]]] = iter([[{"task": "val"}]])
|
||||
|
||||
with pytest.raises(ValueError, match="No beam candidates any more"):
|
||||
await apo._evaluate_and_select_beam(candidates, "seed", val_iterator, round_num=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_best_prompt_updates_history() -> None:
|
||||
apo = APO[Any](Mock(spec=AsyncOpenAI))
|
||||
old_versioned = apo._create_versioned_prompt(PromptTemplate(template="Old", engine="f-string"))
|
||||
new_versioned = apo._create_versioned_prompt(PromptTemplate(template="New", engine="f-string"))
|
||||
apo._history_best_prompt = old_versioned.prompt_template
|
||||
apo._history_best_score = 0.5
|
||||
apo._history_best_version = old_versioned.version
|
||||
apo.evaluate_prompt_on_batch = AsyncMock(return_value=([], 1.2)) # type: ignore[assignment]
|
||||
|
||||
await apo._update_best_prompt([new_versioned], "seed", [{"task": "val"}], round_num=0) # type: ignore
|
||||
|
||||
assert apo._history_best_prompt is new_versioned.prompt_template
|
||||
assert apo._history_best_score == pytest.approx(1.2) # type: ignore
|
||||
assert apo._history_best_version == new_versioned.version
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_best_prompt_keeps_history_when_not_improved() -> None:
|
||||
apo = APO[Any](Mock(spec=AsyncOpenAI))
|
||||
old_versioned = apo._create_versioned_prompt(PromptTemplate(template="Old", engine="f-string"))
|
||||
new_versioned = apo._create_versioned_prompt(PromptTemplate(template="New", engine="f-string"))
|
||||
apo._history_best_prompt = old_versioned.prompt_template
|
||||
apo._history_best_score = 2.0
|
||||
apo._history_best_version = old_versioned.version
|
||||
apo.evaluate_prompt_on_batch = AsyncMock(return_value=([], 1.5)) # type: ignore[assignment]
|
||||
|
||||
await apo._update_best_prompt([new_versioned], "seed", [{"task": "val"}], round_num=0) # type: ignore
|
||||
|
||||
assert apo._history_best_prompt is old_versioned.prompt_template
|
||||
assert apo._history_best_score == pytest.approx(2.0) # type: ignore
|
||||
assert apo._history_best_version == old_versioned.version
|
||||
|
||||
|
||||
def test_apo_init_defaults_run_initial_validation_to_true() -> None:
|
||||
"""Test that run_initial_validation defaults to True when not specified."""
|
||||
client = Mock(spec=AsyncOpenAI)
|
||||
apo = APO[Any](client)
|
||||
|
||||
assert apo.run_initial_validation is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_performs_initial_validation_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that initial validation runs on seed prompt when run_initial_validation=True."""
|
||||
async_client = Mock(spec=AsyncOpenAI)
|
||||
apo = APO[Any](
|
||||
async_client,
|
||||
gradient_batch_size=1,
|
||||
val_batch_size=1,
|
||||
beam_width=1,
|
||||
branch_factor=1,
|
||||
beam_rounds=0, # No optimization rounds, just initial validation
|
||||
run_initial_validation=True,
|
||||
)
|
||||
seed_prompt = PromptTemplate(template="Seed", engine="f-string")
|
||||
apo.set_initial_resources({"seed": seed_prompt})
|
||||
|
||||
store = DummyStore()
|
||||
apo._test_adapter = adapter = DummyTraceMessagesAdapter() # type: ignore
|
||||
apo.set_store(store) # type: ignore
|
||||
apo.set_adapter(adapter)
|
||||
|
||||
# Set up initial validation rollout
|
||||
store.query_spans_map["rollout-0"] = [make_reward_span("rollout-0", "attempt", 0.75, sequence_id=1)]
|
||||
store.wait_results_queue.append(
|
||||
[
|
||||
Rollout(
|
||||
rollout_id="rollout-0",
|
||||
input={"task": "val"},
|
||||
start_time=0.0,
|
||||
status="succeeded",
|
||||
mode="val",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(apo_module.random, "shuffle", lambda seq: None) # type: ignore
|
||||
|
||||
val_dataset = [{"task": "val"}]
|
||||
await apo.run(train_dataset=[{"task": "train"}], val_dataset=val_dataset) # type: ignore
|
||||
|
||||
# Verify initial validation was performed
|
||||
assert apo._history_best_prompt is seed_prompt
|
||||
assert apo._history_best_score == pytest.approx(0.75) # type: ignore
|
||||
|
||||
# Verify a validation rollout was enqueued for initial validation
|
||||
val_calls = [c for c in store.enqueue_calls if c["mode"] == "val"]
|
||||
assert len(val_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_skips_initial_validation_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that initial validation is skipped when run_initial_validation=False."""
|
||||
create_mock = AsyncMock(side_effect=[make_completion("critique text"), make_completion("improved prompt")])
|
||||
async_client = make_openai_client(create_mock)
|
||||
|
||||
apo = APO[Any](
|
||||
async_client,
|
||||
gradient_batch_size=1,
|
||||
val_batch_size=1,
|
||||
beam_width=1,
|
||||
branch_factor=1,
|
||||
beam_rounds=1,
|
||||
run_initial_validation=False, # Disable initial validation
|
||||
)
|
||||
seed_prompt = PromptTemplate(template="Seed", engine="f-string")
|
||||
apo.set_initial_resources({"seed": seed_prompt})
|
||||
|
||||
store = DummyStore()
|
||||
apo._test_adapter = adapter = DummyTraceMessagesAdapter() # type: ignore
|
||||
apo.set_store(store) # type: ignore
|
||||
apo.set_adapter(adapter)
|
||||
|
||||
# Set up spans for rollouts (train + val for candidates + final val)
|
||||
rollout_rewards = [0.4, 0.5, 0.6, 1.1]
|
||||
for i, reward in enumerate(rollout_rewards):
|
||||
store.query_spans_map[f"rollout-{i}"] = [make_reward_span(f"rollout-{i}", "attempt", reward, sequence_id=1)]
|
||||
store.wait_results_queue.append(
|
||||
[
|
||||
Rollout(
|
||||
rollout_id=f"rollout-{i}",
|
||||
input={"task": f"data-{i}"},
|
||||
start_time=0.0,
|
||||
status="succeeded",
|
||||
mode="train" if i == 0 else "val",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(apo_module.random, "shuffle", lambda seq: None) # type: ignore
|
||||
monkeypatch.setattr(apo_module.random, "sample", lambda population, k: list(population)[:k]) # type: ignore
|
||||
monkeypatch.setattr(apo_module.random, "choice", lambda seq: seq[0]) # type: ignore
|
||||
|
||||
train_dataset = [{"task": "train"}]
|
||||
val_dataset = [{"task": "val"}]
|
||||
|
||||
await apo.run(train_dataset=train_dataset, val_dataset=val_dataset) # type: ignore
|
||||
|
||||
# Verify best prompt was updated through normal optimization (not initial validation)
|
||||
best_prompt = apo.get_best_prompt()
|
||||
assert best_prompt.template == "improved prompt"
|
||||
|
||||
# Count validation rollouts - should NOT include initial validation
|
||||
# Only candidate evaluation + final best prompt evaluation
|
||||
val_calls = [c for c in store.enqueue_calls if c["mode"] == "val"]
|
||||
# With run_initial_validation=False, we expect: 2 val calls (seed+new candidate) + 1 final val = 3 total
|
||||
assert len(val_calls) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_updates_best_prompt_with_real_openai_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Integration test for the full run method with minimal mocking."""
|
||||
create_mock = AsyncMock(side_effect=[make_completion("critique text"), make_completion("improved prompt")])
|
||||
async_client = make_openai_client(create_mock)
|
||||
|
||||
apo = APO[Any](
|
||||
async_client,
|
||||
gradient_batch_size=1,
|
||||
val_batch_size=1,
|
||||
beam_width=1,
|
||||
branch_factor=1,
|
||||
beam_rounds=1,
|
||||
run_initial_validation=False, # Skip initial validation for this test
|
||||
)
|
||||
seed_prompt = PromptTemplate(template="Seed", engine="f-string")
|
||||
apo.set_initial_resources({"seed": seed_prompt})
|
||||
|
||||
store = DummyStore()
|
||||
# Keep strong reference to prevent garbage collection since APO uses weakref
|
||||
apo._test_adapter = adapter = DummyTraceMessagesAdapter() # type: ignore
|
||||
apo.set_store(store) # type: ignore
|
||||
apo.set_adapter(adapter)
|
||||
|
||||
# Set up spans for all expected rollouts
|
||||
# For 1 round with beam_width=1, branch_factor=1, run_initial_validation=False, we expect:
|
||||
# 1. Training rollout for gradient computation
|
||||
# 2. Validation rollouts for candidate evaluation (seed + new candidate = 2)
|
||||
# 3. Final validation rollout on full dataset for best prompt
|
||||
rollout_rewards = [0.4, 0.5, 0.6, 1.1]
|
||||
for i, reward in enumerate(rollout_rewards):
|
||||
store.query_spans_map[f"rollout-{i}"] = [make_reward_span(f"rollout-{i}", "attempt", reward, sequence_id=1)]
|
||||
store.wait_results_queue.append(
|
||||
[
|
||||
Rollout(
|
||||
rollout_id=f"rollout-{i}",
|
||||
input={"task": f"data-{i}"},
|
||||
start_time=0.0,
|
||||
status="succeeded",
|
||||
mode="train" if i == 0 else "val",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(apo_module.random, "shuffle", lambda seq: None) # type: ignore
|
||||
monkeypatch.setattr(apo_module.random, "sample", lambda population, k: list(population)[:k]) # type: ignore
|
||||
monkeypatch.setattr(apo_module.random, "choice", lambda seq: seq[0]) # type: ignore
|
||||
|
||||
train_dataset = [{"task": "train"}]
|
||||
val_dataset = [{"task": "val"}]
|
||||
|
||||
await apo.run(train_dataset=train_dataset, val_dataset=val_dataset) # type: ignore
|
||||
|
||||
# Verify best prompt was updated
|
||||
best_prompt = apo.get_best_prompt()
|
||||
assert best_prompt.template == "improved prompt"
|
||||
|
||||
# Verify OpenAI was called twice (gradient + edit)
|
||||
assert create_mock.await_count == 2
|
||||
gradient_call = create_mock.await_args_list[0]
|
||||
assert gradient_call.kwargs["model"] == apo.gradient_model
|
||||
edit_call = create_mock.await_args_list[1]
|
||||
assert edit_call.kwargs["model"] == apo.apply_edit_model
|
||||
|
||||
# Verify resources were updated (seed prompt + new candidate prompts)
|
||||
assert len(store.update_resources_calls) >= 2
|
||||
assert all(isinstance(entry[0], str) for entry in store.update_resources_calls)
|
||||
|
||||
# Verify rollouts were enqueued (1 train + multiple val)
|
||||
assert len(store.enqueue_calls) >= 3
|
||||
train_calls = [c for c in store.enqueue_calls if c["mode"] == "train"]
|
||||
val_calls = [c for c in store.enqueue_calls if c["mode"] == "val"]
|
||||
assert len(train_calls) == 1
|
||||
assert len(val_calls) >= 2
|
||||
|
||||
# Verify history was updated correctly
|
||||
assert apo._history_best_prompt is not None
|
||||
assert apo._history_best_score > 0
|
||||
@@ -0,0 +1,249 @@
|
||||
# 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}"
|
||||
@@ -0,0 +1,362 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Test that @algo decorator preserves function executability."""
|
||||
|
||||
import inspect
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agentlightning.algorithm.decorator import FunctionalAlgorithm, algo
|
||||
from agentlightning.store.base import LightningStore
|
||||
from agentlightning.types import Dataset
|
||||
|
||||
|
||||
@algo
|
||||
def sample_algorithm_func(*, train_dataset: Optional[Dataset[Any]], val_dataset: Optional[Dataset[Any]]) -> None:
|
||||
"""A test function with algorithm decorator."""
|
||||
# Store the datasets in a way we can verify
|
||||
sample_algorithm_func.last_train = train_dataset # type: ignore
|
||||
sample_algorithm_func.last_val = val_dataset # type: ignore
|
||||
|
||||
|
||||
def test_algorithm_preserves_executability():
|
||||
"""Test that @algo decorated functions remain executable."""
|
||||
test_train = ["train1", "train2"]
|
||||
test_val = ["val1"]
|
||||
|
||||
# Function should be callable
|
||||
assert callable(sample_algorithm_func)
|
||||
|
||||
# Function should execute with keyword arguments
|
||||
sample_algorithm_func(train_dataset=test_train, val_dataset=test_val)
|
||||
|
||||
# Verify it was called with the right arguments
|
||||
assert sample_algorithm_func.last_train == test_train # type: ignore
|
||||
assert sample_algorithm_func.last_val == test_val # type: ignore
|
||||
|
||||
|
||||
def test_algorithm_preserves_metadata():
|
||||
"""Test that @algo preserves function metadata."""
|
||||
# Function name should be preserved
|
||||
assert sample_algorithm_func.__name__ == "sample_algorithm_func" # type: ignore
|
||||
|
||||
# Docstring should be preserved
|
||||
assert sample_algorithm_func.__doc__ == "A test function with algorithm decorator."
|
||||
|
||||
|
||||
def test_algorithm_returns_functional_algorithm_instance():
|
||||
"""Test that @algo returns a FunctionalAlgorithm instance."""
|
||||
assert isinstance(sample_algorithm_func, FunctionalAlgorithm)
|
||||
|
||||
# Should have algorithm methods
|
||||
assert hasattr(sample_algorithm_func, "run")
|
||||
assert hasattr(sample_algorithm_func, "get_store")
|
||||
assert hasattr(sample_algorithm_func, "set_trainer")
|
||||
|
||||
|
||||
def test_algorithm_preserves_signature():
|
||||
"""Test that @algo preserves function signature."""
|
||||
sig = inspect.signature(sample_algorithm_func)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
# Should have the expected parameters
|
||||
assert params == ["train_dataset", "val_dataset"]
|
||||
|
||||
|
||||
def test_algorithm_run_method():
|
||||
"""Test that the run method works correctly."""
|
||||
|
||||
@algo
|
||||
def test_algo(*, train_dataset: Optional[Dataset[Any]], val_dataset: Optional[Dataset[Any]]) -> None:
|
||||
"""Test algorithm."""
|
||||
test_algo.executed = True # type: ignore
|
||||
test_algo.train = train_dataset # type: ignore
|
||||
test_algo.val = val_dataset # type: ignore
|
||||
|
||||
test_algo.executed = False # type: ignore
|
||||
|
||||
train_data = ["item1", "item2"]
|
||||
val_data = ["val1"]
|
||||
|
||||
# Call run method
|
||||
test_algo.run(train_data, val_data)
|
||||
|
||||
# Verify execution
|
||||
assert test_algo.executed # type: ignore
|
||||
assert test_algo.train == train_data # type: ignore
|
||||
assert test_algo.val == val_data # type: ignore
|
||||
|
||||
|
||||
def test_algorithm_callable_shortcut():
|
||||
"""Test that calling the instance directly works."""
|
||||
|
||||
@algo
|
||||
def test_algo(*, train_dataset: Optional[Dataset[Any]], val_dataset: Optional[Dataset[Any]]) -> None:
|
||||
"""Test algorithm."""
|
||||
test_algo.called = True # type: ignore
|
||||
|
||||
test_algo.called = False # type: ignore
|
||||
|
||||
# Direct call should work with keyword arguments
|
||||
test_algo(train_dataset=None, val_dataset=None)
|
||||
|
||||
assert test_algo.called # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_function_with_algorithm():
|
||||
"""Test that async functions work with @algo decorator."""
|
||||
|
||||
@algo
|
||||
async def async_algo(*, train_dataset: Optional[Dataset[Any]], val_dataset: Optional[Dataset[Any]]) -> None:
|
||||
"""An async test function."""
|
||||
async_algo.executed = True # type: ignore
|
||||
async_algo.train = train_dataset # type: ignore
|
||||
|
||||
async_algo.executed = False # type: ignore
|
||||
|
||||
# Should be callable
|
||||
assert callable(async_algo)
|
||||
|
||||
# Should preserve async nature when called directly with keyword arguments
|
||||
test_data = ["async-test"]
|
||||
await async_algo(train_dataset=test_data, val_dataset=None)
|
||||
|
||||
assert async_algo.executed # type: ignore
|
||||
assert async_algo.train == test_data # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_algorithm_run_method():
|
||||
"""Test that async algorithms work with the run method."""
|
||||
|
||||
@algo
|
||||
async def async_algo(*, train_dataset: Optional[Dataset[Any]], val_dataset: Optional[Dataset[Any]]) -> None:
|
||||
"""An async algorithm."""
|
||||
async_algo.run_executed = True # type: ignore
|
||||
async_algo.run_train = train_dataset # type: ignore
|
||||
async_algo.run_val = val_dataset # type: ignore
|
||||
|
||||
async_algo.run_executed = False # type: ignore
|
||||
|
||||
train_data = ["async-train"]
|
||||
val_data = ["async-val"]
|
||||
|
||||
# Run method should return an awaitable
|
||||
assert async_algo.is_async()
|
||||
result = async_algo.run(train_data, val_data)
|
||||
assert inspect.iscoroutine(result)
|
||||
|
||||
# Await the result
|
||||
await result
|
||||
|
||||
assert async_algo.run_executed # type: ignore
|
||||
assert async_algo.run_train == train_data # type: ignore
|
||||
assert async_algo.run_val == val_data # type: ignore
|
||||
|
||||
|
||||
def test_algorithm_with_none_datasets():
|
||||
"""Test that algorithm works with None datasets."""
|
||||
|
||||
@algo
|
||||
def nullable_algo(*, train_dataset: Optional[Dataset[Any]], val_dataset: Optional[Dataset[Any]]) -> None:
|
||||
"""Algorithm that accepts None."""
|
||||
nullable_algo.called_with_none = train_dataset is None and val_dataset is None # type: ignore
|
||||
|
||||
nullable_algo(train_dataset=None, val_dataset=None)
|
||||
assert nullable_algo.called_with_none # type: ignore
|
||||
|
||||
# Also test via run method
|
||||
nullable_algo.called_with_none = False # type: ignore
|
||||
nullable_algo.run()
|
||||
assert nullable_algo.called_with_none # type: ignore
|
||||
|
||||
|
||||
def test_multiple_algorithm_instances():
|
||||
"""Test that multiple decorated functions work independently."""
|
||||
|
||||
@algo
|
||||
def algo1(*, train_dataset: Optional[Dataset[Any]], val_dataset: Optional[Dataset[Any]]) -> None:
|
||||
"""First algorithm."""
|
||||
algo1.count = getattr(algo1, "count", 0) + 1 # type: ignore
|
||||
|
||||
@algo
|
||||
def algo2(*, train_dataset: Optional[Dataset[Any]], val_dataset: Optional[Dataset[Any]]) -> None:
|
||||
"""Second algorithm."""
|
||||
algo2.count = getattr(algo2, "count", 0) + 1 # type: ignore
|
||||
|
||||
algo1.count = 0 # type: ignore
|
||||
algo2.count = 0 # type: ignore
|
||||
|
||||
algo1(train_dataset=None, val_dataset=None)
|
||||
algo1(train_dataset=None, val_dataset=None)
|
||||
algo2(train_dataset=None, val_dataset=None)
|
||||
|
||||
assert algo1.count == 2 # type: ignore
|
||||
assert algo2.count == 1 # type: ignore
|
||||
|
||||
|
||||
def test_algorithm_base_algorithm_methods():
|
||||
"""Test that Algorithm methods are available."""
|
||||
|
||||
@algo
|
||||
def test_algo(*, train_dataset: Optional[Dataset[Any]], val_dataset: Optional[Dataset[Any]]) -> None:
|
||||
"""Test algorithm."""
|
||||
pass
|
||||
|
||||
# Should have all Algorithm methods
|
||||
assert hasattr(test_algo, "set_trainer")
|
||||
assert hasattr(test_algo, "get_trainer")
|
||||
assert hasattr(test_algo, "set_llm_proxy")
|
||||
assert hasattr(test_algo, "get_llm_proxy")
|
||||
assert hasattr(test_algo, "set_adapter")
|
||||
assert hasattr(test_algo, "get_adapter")
|
||||
assert hasattr(test_algo, "set_store")
|
||||
assert hasattr(test_algo, "get_store")
|
||||
assert hasattr(test_algo, "get_initial_resources")
|
||||
assert hasattr(test_algo, "set_initial_resources")
|
||||
|
||||
|
||||
# New tests for parameter injection and error handling
|
||||
|
||||
|
||||
def test_algorithm_without_datasets():
|
||||
"""Test that algorithms can be defined without train_dataset/val_dataset parameters."""
|
||||
|
||||
@algo
|
||||
def no_dataset_algo(*, store: LightningStore) -> None:
|
||||
"""Algorithm that doesn't use datasets."""
|
||||
no_dataset_algo.store_passed = store # type: ignore
|
||||
no_dataset_algo.executed = True # type: ignore
|
||||
|
||||
no_dataset_algo.executed = False # type: ignore
|
||||
|
||||
# Set up the store
|
||||
mock_store = MagicMock(spec=LightningStore)
|
||||
no_dataset_algo.set_store(mock_store)
|
||||
|
||||
# Call run method without datasets
|
||||
no_dataset_algo.run()
|
||||
|
||||
assert no_dataset_algo.executed # type: ignore
|
||||
assert no_dataset_algo.store_passed == mock_store # type: ignore
|
||||
|
||||
|
||||
def test_algorithm_raises_error_on_unsupported_train_dataset():
|
||||
"""Test that TypeError is raised when train_dataset is provided but not supported."""
|
||||
|
||||
@algo
|
||||
def no_train_algo(*, val_dataset: Optional[Dataset[Any]]) -> None:
|
||||
"""Algorithm that only accepts val_dataset."""
|
||||
pass
|
||||
|
||||
# Providing train_dataset should raise TypeError
|
||||
with pytest.raises(TypeError, match="train_dataset is provided but not supported"):
|
||||
no_train_algo.run(train_dataset=["data"], val_dataset=None)
|
||||
|
||||
|
||||
def test_algorithm_raises_error_on_unsupported_val_dataset():
|
||||
"""Test that TypeError is raised when val_dataset is provided but not supported."""
|
||||
|
||||
@algo
|
||||
def no_val_algo(*, train_dataset: Optional[Dataset[Any]]) -> None:
|
||||
"""Algorithm that only accepts train_dataset."""
|
||||
pass
|
||||
|
||||
# Providing val_dataset should raise TypeError
|
||||
with pytest.raises(TypeError, match="val_dataset is provided but not supported"):
|
||||
no_val_algo.run(train_dataset=None, val_dataset=["data"])
|
||||
|
||||
|
||||
def test_algorithm_with_all_injected_parameters():
|
||||
"""Test that all injectable parameters (store, adapter, llm_proxy, initial_resources) work."""
|
||||
|
||||
@algo
|
||||
def full_algo(
|
||||
*,
|
||||
store: LightningStore,
|
||||
adapter: Any,
|
||||
llm_proxy: Optional[Any] = None,
|
||||
initial_resources: Optional[Any] = None,
|
||||
train_dataset: Optional[Dataset[Any]],
|
||||
val_dataset: Optional[Dataset[Any]],
|
||||
) -> None:
|
||||
"""Algorithm with all injectable parameters."""
|
||||
full_algo.store = store # type: ignore
|
||||
full_algo.adapter = adapter # type: ignore
|
||||
full_algo.llm_proxy = llm_proxy # type: ignore
|
||||
full_algo.initial_resources = initial_resources # type: ignore
|
||||
full_algo.train = train_dataset # type: ignore
|
||||
full_algo.val = val_dataset # type: ignore
|
||||
|
||||
# Set up all dependencies
|
||||
mock_store = MagicMock(spec=LightningStore)
|
||||
mock_adapter = MagicMock()
|
||||
mock_llm_proxy = MagicMock()
|
||||
mock_resources = MagicMock()
|
||||
|
||||
full_algo.set_store(mock_store)
|
||||
full_algo.set_adapter(mock_adapter)
|
||||
full_algo.set_llm_proxy(mock_llm_proxy)
|
||||
full_algo.set_initial_resources(mock_resources)
|
||||
|
||||
train_data = ["train"]
|
||||
val_data = ["val"]
|
||||
|
||||
# Run the algorithm
|
||||
full_algo.run(train_data, val_data)
|
||||
|
||||
# Verify all parameters were injected correctly
|
||||
assert full_algo.store == mock_store # type: ignore
|
||||
assert full_algo.adapter == mock_adapter # type: ignore
|
||||
assert full_algo.llm_proxy == mock_llm_proxy # type: ignore
|
||||
assert full_algo.initial_resources == mock_resources # type: ignore
|
||||
assert full_algo.train == train_data # type: ignore
|
||||
assert full_algo.val == val_data # type: ignore
|
||||
|
||||
|
||||
def test_algorithm_with_only_store():
|
||||
"""Test algorithm that only uses the store parameter."""
|
||||
|
||||
@algo
|
||||
def store_only_algo(*, store: LightningStore) -> None:
|
||||
"""Algorithm that only needs store."""
|
||||
store_only_algo.got_store = True # type: ignore
|
||||
store_only_algo.store_value = store # type: ignore
|
||||
|
||||
store_only_algo.got_store = False # type: ignore
|
||||
|
||||
mock_store = MagicMock(spec=LightningStore)
|
||||
store_only_algo.set_store(mock_store)
|
||||
|
||||
# Should work without any datasets
|
||||
store_only_algo.run()
|
||||
|
||||
assert store_only_algo.got_store # type: ignore
|
||||
assert store_only_algo.store_value == mock_store # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_algorithm_with_injected_parameters():
|
||||
"""Test that async algorithms also support parameter injection."""
|
||||
|
||||
@algo
|
||||
async def async_full_algo(
|
||||
*,
|
||||
store: LightningStore,
|
||||
train_dataset: Optional[Dataset[Any]],
|
||||
) -> None:
|
||||
"""Async algorithm with injected parameters."""
|
||||
async_full_algo.store = store # type: ignore
|
||||
async_full_algo.train = train_dataset # type: ignore
|
||||
|
||||
mock_store = MagicMock(spec=LightningStore)
|
||||
async_full_algo.set_store(mock_store) # type: ignore
|
||||
|
||||
train_data = ["async-train"]
|
||||
await async_full_algo.run(train_data) # type: ignore
|
||||
|
||||
assert async_full_algo.store == mock_store # type: ignore
|
||||
assert async_full_algo.train == train_data # type: ignore
|
||||
@@ -0,0 +1,115 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agentlightning.algorithm.base import Algorithm
|
||||
from agentlightning.algorithm.utils import with_llm_proxy, with_store
|
||||
from agentlightning.llm_proxy import LLMProxy
|
||||
from agentlightning.store.base import LightningStore
|
||||
|
||||
|
||||
class _BaseAlgorithm(Algorithm):
|
||||
def run(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Satisfy the abstract interface without invoking training logic."""
|
||||
return None
|
||||
|
||||
|
||||
class _StubLLMProxy:
|
||||
"""Test double that tracks lifecycle calls."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.start_calls = 0
|
||||
self.stop_calls = 0
|
||||
self.running = False
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self.running
|
||||
|
||||
async def start(self) -> None:
|
||||
self.start_calls += 1
|
||||
self.running = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.stop_calls += 1
|
||||
self.running = False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_store_injects_store_argument():
|
||||
class StoreAlgorithm(_BaseAlgorithm):
|
||||
@with_store
|
||||
async def record_store(self, store: LightningStore, payload: str) -> None:
|
||||
self.seen_store = store # type: ignore[attr-defined]
|
||||
self.seen_payload = payload # type: ignore[attr-defined]
|
||||
|
||||
algorithm = StoreAlgorithm()
|
||||
fake_store = MagicMock(spec=LightningStore)
|
||||
algorithm.set_store(fake_store)
|
||||
|
||||
await algorithm.record_store("batch-1")
|
||||
|
||||
assert algorithm.seen_store is fake_store # type: ignore[attr-defined]
|
||||
assert algorithm.seen_payload == "batch-1" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_llm_proxy_allows_optional_injection():
|
||||
class OptionalProxyAlgorithm(_BaseAlgorithm):
|
||||
@with_llm_proxy()
|
||||
async def record_proxy(self, llm_proxy: LLMProxy | None, marker: str) -> None:
|
||||
self.seen_proxy = llm_proxy # type: ignore[attr-defined]
|
||||
self.marker = marker # type: ignore[attr-defined]
|
||||
|
||||
algorithm = OptionalProxyAlgorithm()
|
||||
algorithm.set_llm_proxy(None)
|
||||
|
||||
await algorithm.record_proxy("optional")
|
||||
|
||||
assert algorithm.seen_proxy is None # type: ignore[attr-defined]
|
||||
assert algorithm.marker == "optional" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_llm_proxy_required_raises_when_missing():
|
||||
class RequiredProxyAlgorithm(_BaseAlgorithm):
|
||||
@with_llm_proxy(required=True)
|
||||
async def record_proxy(self, llm_proxy: LLMProxy) -> None:
|
||||
self.seen_proxy = llm_proxy # type: ignore[attr-defined]
|
||||
|
||||
algorithm = RequiredProxyAlgorithm()
|
||||
algorithm.set_llm_proxy(None)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await algorithm.record_proxy()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_llm_proxy_auto_start_and_stop():
|
||||
class AutoProxyAlgorithm(_BaseAlgorithm):
|
||||
@with_llm_proxy()
|
||||
async def use_proxy(self, llm_proxy: LLMProxy | None) -> None:
|
||||
if llm_proxy is None:
|
||||
raise AssertionError("LLM proxy should be injected")
|
||||
self.seen_proxy = llm_proxy # type: ignore[attr-defined]
|
||||
|
||||
algorithm = AutoProxyAlgorithm()
|
||||
proxy = _StubLLMProxy()
|
||||
algorithm.set_llm_proxy(cast(LLMProxy, proxy))
|
||||
|
||||
await algorithm.use_proxy()
|
||||
|
||||
assert algorithm.seen_proxy is proxy # type: ignore[attr-defined]
|
||||
assert proxy.start_calls == 1
|
||||
assert proxy.stop_calls == 1
|
||||
|
||||
# When already running, no extra start/stop should be requested.
|
||||
proxy.running = True
|
||||
await algorithm.use_proxy()
|
||||
|
||||
assert proxy.start_calls == 1
|
||||
assert proxy.stop_calls == 1
|
||||
Reference in New Issue
Block a user