Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 289c9ca25b | |||
| 3ed230f439 | |||
| 1d2515c04e | |||
| 88e28be0bd | |||
| f8a3082646 | |||
| ef2e90ccaa | |||
| 2a5bdbf661 | |||
| 01101a64ba | |||
| ef346fac0f | |||
| 41c6ec2bd9 |
@@ -137,6 +137,22 @@ jobs:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
|
||||
- name: Calc-X training with local model
|
||||
run: |
|
||||
set -ex
|
||||
source .venv/bin/activate
|
||||
cd examples/calc_x
|
||||
../../scripts/restart_ray.sh
|
||||
sleep 5
|
||||
hf download Qwen/Qwen2.5-0.5B-Instruct --local-dir data/qwen_model
|
||||
PYTHONUNBUFFERED=1 python train_calc_agent.py --val-file data/test_mini.parquet --ci --model $(realpath data/qwen_model)
|
||||
sleep 10
|
||||
shell: bash
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
id: calc_x_train_local_model
|
||||
|
||||
- name: Calc-X training LLM Proxy
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
@@ -54,6 +54,10 @@ jobs:
|
||||
run: |
|
||||
uv sync --frozen --no-default-groups --extra apo --extra verl \
|
||||
--group dev --group experiment --group agents --group torch-gpu-${{ matrix.setup-script }}
|
||||
- name: Override VERL (stable)
|
||||
run: |
|
||||
uv pip install verl==0.5.0
|
||||
if: matrix.setup-script == 'stable'
|
||||
- name: Freeze dependencies
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
@@ -189,6 +189,9 @@ cython_debug/
|
||||
# you could uncomment the following to ignore the enitre vscode folder
|
||||
.vscode/
|
||||
|
||||
# Emacs backup files
|
||||
*~
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
__version__ = "0.2.1"
|
||||
__version__ = "0.2.2"
|
||||
|
||||
from .adapter import *
|
||||
from .algorithm import *
|
||||
|
||||
@@ -99,6 +99,8 @@ class VERL(Algorithm):
|
||||
|
||||
# Merge your dict overrides
|
||||
override_conf = OmegaConf.create(config)
|
||||
# Allow adding new fields
|
||||
OmegaConf.set_struct(base_cfg, False)
|
||||
self.config = OmegaConf.merge(base_cfg, override_conf)
|
||||
|
||||
def run(
|
||||
|
||||
@@ -6,12 +6,15 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
from agentlightning.logging import configure_logger
|
||||
from agentlightning.store.client_server import LightningStoreServer
|
||||
from agentlightning.store.memory import InMemoryLightningStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Run a LightningStore server")
|
||||
@@ -22,7 +25,11 @@ def main(argv: Iterable[str] | None = None) -> int:
|
||||
|
||||
store = InMemoryLightningStore()
|
||||
server = LightningStoreServer(store, host="0.0.0.0", port=args.port)
|
||||
asyncio.run(server.run_forever())
|
||||
try:
|
||||
asyncio.run(server.run_forever())
|
||||
except RuntimeError as exc:
|
||||
logger.error("LightningStore server failed to start: %s", exc, exc_info=True)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ _original_handle_chat_attributes: Callable[..., Any] | None = None
|
||||
_original_handle_response: Callable[..., Any] | None = None
|
||||
|
||||
|
||||
def _unwrap_legacy_response(response: Any) -> Any:
|
||||
if hasattr(response, "parse") and callable(response.parse):
|
||||
return response.parse()
|
||||
return response
|
||||
|
||||
|
||||
def _patch_new_agentops():
|
||||
import agentops.instrumentation.providers.openai.stream_wrapper
|
||||
import agentops.instrumentation.providers.openai.wrappers.chat
|
||||
@@ -44,6 +50,11 @@ def _patch_new_agentops():
|
||||
@no_type_check
|
||||
def _handle_chat_attributes_with_tokens(args=None, kwargs=None, return_value=None, **kws): # type: ignore
|
||||
attributes = _original_handle_chat_attributes(args=args, kwargs=kwargs, return_value=return_value, **kws)
|
||||
|
||||
# In some cases, response is a openai._legacy_response.LegacyAPIResponse (e.g., LiteLLM, or LangChain),
|
||||
# This is created by client.with_raw_response.create()
|
||||
return_value = _unwrap_legacy_response(return_value)
|
||||
|
||||
if (
|
||||
return_value is not None
|
||||
and hasattr(return_value, "prompt_token_ids")
|
||||
@@ -89,20 +100,6 @@ def _patch_new_agentops():
|
||||
[logprob.model_dump() for logprob in first_choice.logprobs.refusal]
|
||||
)
|
||||
|
||||
# For LiteLLM, response is a openai._legacy_response.LegacyAPIResponse
|
||||
if (
|
||||
return_value is not None
|
||||
and hasattr(return_value, "http_response")
|
||||
and return_value.http_response is not None
|
||||
and hasattr(return_value.http_response, "json")
|
||||
):
|
||||
json_data = return_value.http_response.json()
|
||||
if isinstance(json_data, dict):
|
||||
if json_data.get("prompt_token_ids") is not None:
|
||||
attributes["prompt_token_ids"] = list(json_data["prompt_token_ids"])
|
||||
if json_data.get("response_token_ids") is not None:
|
||||
attributes["response_token_ids"] = list(json_data["response_token_ids"][0])
|
||||
|
||||
return attributes
|
||||
|
||||
agentops.instrumentation.providers.openai.wrappers.chat.handle_chat_attributes = _handle_chat_attributes_with_tokens
|
||||
|
||||
@@ -109,6 +109,7 @@ class LightningStoreServer(LightningStore):
|
||||
self._uvicorn_server: uvicorn.Server | None = uvicorn.Server(self._uvicorn_config)
|
||||
|
||||
self._serving_thread: Optional[threading.Thread] = None
|
||||
self._server_start_exception: Optional[BaseException] = None
|
||||
|
||||
# Process-awareness:
|
||||
# LightningStoreServer holds a plain Python object (self.store) in one process
|
||||
@@ -167,17 +168,45 @@ class LightningStoreServer(LightningStore):
|
||||
logger.info(f"Starting server at {self.endpoint}")
|
||||
|
||||
uvicorn_server = self._uvicorn_server
|
||||
self._server_start_exception = None
|
||||
|
||||
def run_server_forever():
|
||||
asyncio.run(uvicorn_server.serve())
|
||||
try:
|
||||
asyncio.run(uvicorn_server.serve())
|
||||
except (SystemExit, Exception) as exc:
|
||||
logger.debug("LightningStore server thread exiting due to %s", exc, exc_info=exc)
|
||||
self._server_start_exception = exc
|
||||
|
||||
self._serving_thread = threading.Thread(target=run_server_forever, daemon=True)
|
||||
self._serving_thread.start()
|
||||
serving_thread = threading.Thread(target=run_server_forever, daemon=True)
|
||||
self._serving_thread = serving_thread
|
||||
serving_thread.start()
|
||||
|
||||
# Wait for /health to be available
|
||||
if not await self._server_health_check():
|
||||
# Wait for uvicorn to report that it has started before pinging /health.
|
||||
start_deadline = time.time() + 10
|
||||
while time.time() < start_deadline:
|
||||
if uvicorn_server.started:
|
||||
break
|
||||
if self._server_start_exception is not None or not serving_thread.is_alive():
|
||||
self._handle_failed_start()
|
||||
raise RuntimeError(self._format_start_failure_reason())
|
||||
await asyncio.sleep(0.05)
|
||||
else:
|
||||
self._handle_failed_start()
|
||||
raise RuntimeError("Server failed to start within the 10 seconds.")
|
||||
|
||||
# Wait for /health to be available once uvicorn reports started.
|
||||
if not await self._server_health_check():
|
||||
self._handle_failed_start()
|
||||
raise RuntimeError("Server failed to start within the 10 seconds.")
|
||||
|
||||
# If startup failed (e.g. port already in use), uvicorn never flips `started`
|
||||
# and the worker thread stops immediately. Guard against latching on to a
|
||||
# different process that happened to satisfy the health check.
|
||||
if not uvicorn_server.started or not serving_thread.is_alive() or self._server_start_exception is not None:
|
||||
self._handle_failed_start()
|
||||
failure_reason = self._format_start_failure_reason()
|
||||
raise RuntimeError(failure_reason)
|
||||
|
||||
async def _server_health_check(self) -> bool:
|
||||
"""Checks if the server is healthy."""
|
||||
current_time = time.time()
|
||||
@@ -190,12 +219,32 @@ class LightningStoreServer(LightningStore):
|
||||
await asyncio.sleep(0.1)
|
||||
return False
|
||||
|
||||
def _handle_failed_start(self) -> None:
|
||||
"""Clean up thread state when startup fails."""
|
||||
if self._uvicorn_server is not None:
|
||||
self._uvicorn_server.should_exit = True
|
||||
if self._serving_thread is not None:
|
||||
# Thread already exited in most failure scenarios; join defensively.
|
||||
self._serving_thread.join(timeout=0.1)
|
||||
self._serving_thread = None
|
||||
|
||||
def _format_start_failure_reason(self) -> str:
|
||||
base_message = f"LightningStore server failed to start on {self.endpoint}."
|
||||
if isinstance(self._server_start_exception, SystemExit):
|
||||
return f"{base_message} Another process may already be using this port."
|
||||
if isinstance(self._server_start_exception, OSError):
|
||||
return f"{base_message} {self._server_start_exception.strerror}."
|
||||
if self._server_start_exception is not None:
|
||||
return f"{base_message} Reason: {self._server_start_exception}."
|
||||
return f"{base_message} Another process may already be using this port."
|
||||
|
||||
async def run_forever(self):
|
||||
"""Runs the FastAPI server indefinitely.
|
||||
|
||||
You need to call this method in the same process as the server was created in.
|
||||
"""
|
||||
assert self._uvicorn_server is not None
|
||||
uvicorn_server = self._uvicorn_server
|
||||
|
||||
async def _wait_till_healthy():
|
||||
health = await self._server_health_check()
|
||||
@@ -203,9 +252,30 @@ class LightningStoreServer(LightningStore):
|
||||
raise RuntimeError("Server did not become healthy within the 10 seconds.")
|
||||
logger.info("Store server is online at %s", self.endpoint)
|
||||
|
||||
async def _serve_capture():
|
||||
try:
|
||||
await uvicorn_server.serve()
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except (SystemExit, Exception) as exc:
|
||||
logger.debug("LightningStore server serve() raised %s", exc, exc_info=exc)
|
||||
self._server_start_exception = exc
|
||||
raise RuntimeError("LightningStore server failed to serve") from exc
|
||||
|
||||
# We run _wait_till_healthy and self._uvicorn_server.serve in parallel
|
||||
# until one of them raises an exception.
|
||||
await asyncio.gather(_wait_till_healthy(), self._uvicorn_server.serve())
|
||||
try:
|
||||
await asyncio.gather(_wait_till_healthy(), _serve_capture())
|
||||
except BaseException as exc:
|
||||
if isinstance(exc, KeyboardInterrupt):
|
||||
raise
|
||||
startup_failed = not uvicorn_server.started or isinstance(
|
||||
self._server_start_exception, (SystemExit, OSError)
|
||||
)
|
||||
if startup_failed:
|
||||
self._handle_failed_start()
|
||||
raise RuntimeError(self._format_start_failure_reason())
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
"""Gracefully stops the running FastAPI server.
|
||||
|
||||
@@ -369,6 +369,9 @@ class InMemoryLightningStore(LightningStore):
|
||||
self._attempts[rollout.rollout_id] = []
|
||||
self._attempts[rollout.rollout_id].append(attempt)
|
||||
|
||||
# Sync attempt status to rollout
|
||||
await self._update_rollout_unlocked(rollout.rollout_id, status="preparing")
|
||||
|
||||
return AttemptedRollout(**rollout.model_dump(), attempt=attempt)
|
||||
|
||||
# If not in queuing state, skip this rollout and continue
|
||||
@@ -413,6 +416,9 @@ class InMemoryLightningStore(LightningStore):
|
||||
self._attempts[rollout_id] = []
|
||||
self._attempts[rollout_id].append(attempt)
|
||||
|
||||
# Sync attempt status to rollout
|
||||
await self._update_rollout_unlocked(rollout_id, status="preparing")
|
||||
|
||||
self._completion_events.setdefault(rollout.rollout_id, threading.Event())
|
||||
|
||||
return AttemptedRollout(**rollout.model_dump(), attempt=attempt)
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
# type: ignore
|
||||
|
||||
from importlib.metadata import version
|
||||
from typing import Any
|
||||
|
||||
import hydra
|
||||
import ray
|
||||
from packaging import version as packaging_version
|
||||
from verl.trainer.main_ppo import create_rl_sampler
|
||||
from verl.trainer.ppo.reward import load_reward_manager
|
||||
|
||||
@@ -39,11 +41,17 @@ def run_ppo(
|
||||
) -> None:
|
||||
if not ray.is_initialized():
|
||||
# this is for local ray cluster
|
||||
try:
|
||||
# verl >= 0.6.0
|
||||
num_cpus = config.ray_kwargs.ray_init.num_cpus
|
||||
except AttributeError:
|
||||
# verl < 0.6.0
|
||||
num_cpus = config.ray_init.num_cpus
|
||||
ray.init(
|
||||
runtime_env={
|
||||
"env_vars": {"TOKENIZERS_PARALLELISM": "true", "NCCL_DEBUG": "WARN", "VLLM_LOGGING_LEVEL": "WARN"}
|
||||
},
|
||||
num_cpus=config.ray_init.num_cpus,
|
||||
num_cpus=num_cpus,
|
||||
)
|
||||
|
||||
runner = TaskRunner.remote()
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Dict, Tuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import verl
|
||||
from codetiming import Timer
|
||||
from omegaconf import OmegaConf
|
||||
from tqdm import tqdm
|
||||
@@ -298,14 +299,20 @@ class AgentLightningTrainer(RayPPOTrainer):
|
||||
assert self.async_rollout_mode, "If agent mode is enabled, async server must be enabled"
|
||||
if self.adapter is not None and not isinstance(self.adapter, TraceToTripletBase):
|
||||
raise ValueError("Adapter must be a TraceToTripletBase for currently VERL implementation.")
|
||||
verl_version = verl.__version__
|
||||
if verl_version == "0.5.0":
|
||||
# Note (Zhiyuan): To avoid further patch into vllm async server, using the same sentence to get the naming here.
|
||||
# However, it is possible that verl updates the naming and causes incompatibility.
|
||||
# Reference: https://github.com/volcengine/verl/blob/5b5e09d9cc20625e436d01f69d9cc739ff681c54/verl/workers/rollout/vllm_rollout/vllm_async_server.py#L217
|
||||
model = "/".join(self.config.actor_rollout_ref.model.path.split("/")[-2:])
|
||||
else:
|
||||
# For other versions (e.g., 0.6.0), we use the full path to the model.
|
||||
model = self.config.actor_rollout_ref.model.path
|
||||
self.agent_mode_daemon = AgentModeDaemon(
|
||||
self.config.agentlightning.port,
|
||||
self.config.actor_rollout_ref.rollout.n,
|
||||
train_information={
|
||||
# Note (Zhiyuan): To avoid further patch into vllm async server, using the same sentence to get the naming here.
|
||||
# However, it is possible that verl updates the naming and causes incompatibility.
|
||||
# Reference: https://github.com/volcengine/verl/blob/5b5e09d9cc20625e436d01f69d9cc739ff681c54/verl/workers/rollout/vllm_rollout/vllm_async_server.py#L217
|
||||
"model": "/".join(self.config.actor_rollout_ref.model.path.split("/")[-2:]),
|
||||
"model": model,
|
||||
"temperature": self.config.actor_rollout_ref.rollout.temperature,
|
||||
},
|
||||
tokenizer=self.tokenizer,
|
||||
|
||||
@@ -32,7 +32,7 @@ pip install agentlightning[verl]
|
||||
|
||||
!!! note
|
||||
|
||||
The resource type created by [VERL][agentlightning.algorithm.verl.VERL] is actually a [ProxyLLM][agentlightning.ProxyLLM], a subclass of the [LLM][agentlightning.LLM] type. This object contains a **URL template** provided by [VERL][agentlightning.algorithm.verl.VERL], with placeholders for rollout and attempt IDs. When a rollout begins on the agent side, the framework uses the current `rollout_id` and `attempt_id` to format this template, generating a final, unique endpoint URL. This URL points to [VERL][agentlightning.algorithm.verl.VERL]'s internal proxy, allowing it to intercept and log all traffic for that specific attempt, for tracing and load balancing purposes. For agents created with the `@rollout` decorator, this resolution of the template is handled automatically ("auto-stripped"). Class-based agents will need to manually resolve the `ProxyLLM` using the rollout context.
|
||||
The resource type created by [VERL][agentlightning.algorithm.verl.VERL] is actually a [ProxyLLM][agentlightning.ProxyLLM], a subclass of the [LLM][agentlightning.LLM] type. This object contains a **URL template** provided by [VERL][agentlightning.algorithm.verl.VERL], with placeholders for rollout and attempt IDs. When a rollout begins on the agent side, the framework uses the current `rollout_id` and `attempt_id` to format this template, generating a final, unique endpoint URL. This URL points to [VERL][agentlightning.algorithm.verl.VERL]'s internal proxy, allowing it to intercept and log all traffic for that specific attempt, for tracing and load balancing purposes. For agents created with the `@rollout` decorator, this resolution of the template is handled automatically ("auto-stripped"). Class-based agents will need to manually resolve the [`ProxyLLM`][agentlightning.ProxyLLM] using the rollout context.
|
||||
|
||||
```python
|
||||
proxy_llm = resources["main_llm"]
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Changelog
|
||||
|
||||
## Agent-lightning v0.2.2 (11/12/2025)
|
||||
|
||||
Agent-lightning v0.2.2 is a stabilization release for v0.2.1. It introduces several bug fixes.
|
||||
|
||||
* Fix compatibility issues with VERL 0.6.0.
|
||||
* Fix model name for pre-downloaded models in VERL.
|
||||
* Fix preparing status transition on rollout when creating attempts.
|
||||
* Fix OpenAI Agents SDK compatibility issues.
|
||||
|
||||
**Full Changelog**: https://github.com/microsoft/agent-lightning/compare/v0.2.1...v0.2.2
|
||||
|
||||
---
|
||||
|
||||
## Agent-lightning v0.2.1 (10/30/2025)
|
||||
|
||||
Agent-lightning v0.2.1 is a stabilization release for v0.2.0. It introduces several bug fixes and new features, plus a number of unlisted CI improvements.
|
||||
|
||||
### Bug fixes
|
||||
|
||||
* Fix LiteLLM issues when restarting the proxy multiple times in the same process (#174 #206)
|
||||
* Fix LiteLLM model name selection when multiple servers use the same model (#197)
|
||||
* Fix store port conflict handling (#227)
|
||||
|
||||
### New Features
|
||||
|
||||
* Add trainer port option for client-server strategies (#198)
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add tutorial for launching workers on separate machines (#213)
|
||||
* Add link to VERL framework (#210)
|
||||
* Add link to vLLM blog (#215)
|
||||
* Fix a couple of typos and avoid emacs backup files (#237)
|
||||
|
||||
### New Contributors
|
||||
|
||||
A warm welcome to our first-time contributors: @scott-vsi, @ddsfda99, @jeis4wpi 🎉
|
||||
|
||||
**Full Changelog**: https://github.com/microsoft/agent-lightning/compare/v0.2.0...v0.2.1
|
||||
|
||||
---
|
||||
|
||||
## Agent-lightning v0.2.0 (10/22/2025)
|
||||
|
||||
Agent-Lightning v0.2.0 introduces major framework improvements, new execution strategies, expanded documentation, and enhanced reliability across the agent training and deployment workflow. This release includes **78 pull requests** since v0.1.2.
|
||||
|
||||
### Core Enhancements
|
||||
|
||||
* **Lightning Store**: Added unified interface and implementation for Agent-lightning's core storage.
|
||||
* **Emitter**: Emitting any objects as spans to the store.
|
||||
* **Adapter** and **Tracer**: Adapting to OpenAI-like messages, and OpenTelemetry dummy tracer.
|
||||
* **LLM Proxy**: Added LLM Proxy as the first-class citizen in Agent-lightning.
|
||||
* **Agent Runner**: New version providing a more modular and robust runner design.
|
||||
* **Embedded Algorithms**: Algorithms are now embedded directly into trainers for simplicity.
|
||||
* **New Execution Strategies**: Introduced *Client-Server* and *Shared Memory* execution models.
|
||||
* **Trainer Updates**: Integrated v0.2 interfaces and FastAlgorithm validation.
|
||||
|
||||
### Documentation & Examples
|
||||
|
||||
* Revamped documentation with new guides for **agent creation**, **training**, **debugging**, and **store concepts**.
|
||||
* Improved quickstart tutorials, clarified installation and new deep-dive articles.
|
||||
* Added and updated examples: *SQL Agent*, *Calc-X*, *Local SFT*, *Search-R1*, and *APO algorithm*.
|
||||
|
||||
### Developer Experience
|
||||
|
||||
* Migrated build and CI pipelines to **1ES**, split workflows and aggregate badges for clarity.
|
||||
* Adopted **uv** as the dependency manager.
|
||||
* Added GPU-based pytest workflows for full test coverage.
|
||||
* Enhanced debugging UX, pre-commit configs, and linting (Pyright fixes, import sorting).
|
||||
|
||||
### Ecosystem & Integrations
|
||||
|
||||
* Added support for agents built with [**Agent-framework**](https://github.com/microsoft/agent-framework).
|
||||
* Added new community listings: [*DeepWerewolf*](https://github.com/af-74413592/DeepWerewolf) and [*AgentFlow*](https://agentflow.stanford.edu/).
|
||||
|
||||
### New Contributors
|
||||
|
||||
A warm welcome to our first-time contributors:
|
||||
@hzy46, @lunaqiu, @syeehyn, @linhx1999, @SiyunZhao, and @acured 🎉
|
||||
|
||||
**Full changelog:** [v0.1.2 → v0.2.0](https://github.com/microsoft/agent-lightning/compare/v0.1.2...v0.2.0)
|
||||
|
||||
---
|
||||
|
||||
## Agent-lightning v0.1.2 (08/12/2025)
|
||||
|
||||
### What's Changed
|
||||
* Add basic documentation in https://github.com/microsoft/agent-lightning/pull/33
|
||||
* RAG example by @wizardlancet in https://github.com/microsoft/agent-lightning/pull/21
|
||||
|
||||
### New Contributors
|
||||
* @wizardlancet made their first contribution in https://github.com/microsoft/agent-lightning/pull/21
|
||||
|
||||
**Full Changelog**: https://github.com/microsoft/agent-lightning/compare/v0.1.1...v0.1.2
|
||||
|
||||
---
|
||||
|
||||
## Agent-lightning v0.1.1 (08/06/2025)
|
||||
|
||||
### What's Changed
|
||||
* Disable HTTP tracer tests and bump to 0.1.1 in https://github.com/microsoft/agent-lightning/pull/26
|
||||
* Fix trainer bugs in v0.1 in https://github.com/microsoft/agent-lightning/pull/24
|
||||
|
||||
**Full Changelog**: https://github.com/microsoft/agent-lightning/compare/v0.1...v0.1.1
|
||||
|
||||
---
|
||||
|
||||
## Agent-lightning v0.1.0 (08/04/2025)
|
||||
|
||||
The first release of Agent-lightning!
|
||||
|
||||
- Turn your agent into an optimizable beast with **ZERO CODE CHANGE** (almost)! 💤
|
||||
- Build with **ANY** agent framework (LangChain, OpenAI Agent SDK, AutoGen, CrewAI, ...); or even WITHOUT agent framework (Python OpenAI). You name it! 🤖
|
||||
- **Selectively** optimize one or more agents in a multi-agent system. 🎯
|
||||
- Embraces Reinforcement Learning, Automatic Prompt Optimization and more **algorithms**. 🤗
|
||||
|
||||
Install via `pip install agentlightning`.
|
||||
@@ -496,7 +496,7 @@ flowchart TD
|
||||
Continuous learning keeps the algorithm loop running while runners report tasks and spans opportunistically. Key differences from batch mode:
|
||||
|
||||
1. The algorithm does not enqueue rollouts from a fixed dataset. Runners report tasks/rollouts and spans spontaneously.
|
||||
2. The algorithm can wait for rollouts with a expected set of rollout IDs, but more oftenly polls for new rollouts and spans or waits for a count to arrive.
|
||||
2. The algorithm can wait for rollouts with a expected set of rollout IDs, but more often polls for new rollouts and spans or waits for a count to arrive.
|
||||
3. The [`Runner`][agentlightning.Runner] processes one rollout at a time via [`step(task)`][agentlightning.Runner.step] instead of exhausting a task queue. It notifies the store when starting a rollout so the store records it.
|
||||
4. A user or higher-level loop controls which resources the next step uses and when to retry.
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ dev_data = pd.read_parquet("data/test_dev_500.parquet").to_dict("records")[:10]
|
||||
trainer.dev(agent, dev_dataset=dev_data)
|
||||
```
|
||||
|
||||
Run this in a Python session or adapt your script to include a `--dev` flag. Once the spans appear healthy and the rewards are non-zero, switch back to [`trainer.fit(...)`][agentlightning.Trainer.fit] for full RL training.
|
||||
Run this in a Python session or adapt your script to include a `--dev` flag. Once the spans appear healthy and the rewards are non-zero, switch back to [`trainer.fit(...)`][agentlightning.Trainer.fit] for full RL training. See the [debugging tutorial](../tutorials/debug.md) for more tips on how to debug the agent.
|
||||
|
||||
## Running the Sample Code
|
||||
|
||||
|
||||
@@ -66,6 +66,48 @@ Swap in an [`AgentOpsTracer`][agentlightning.AgentOpsTracer] instead of [`OtelTr
|
||||
|
||||
You can also call [`Runner.step`][agentlightning.Runner.step] to inject ad-hoc rollouts into a running store being used by another algorithm, so that the rollouts can be consumed by the algorithms. This is very recently known as the paradigm of ["online RL"](https://cursor.com/blog/tab-rl). At the moment, no algorithm in the [algorithm zoo](../algorithm-zoo/index.md) consumes externally generated rollouts, but the data flow is available there if you need it.
|
||||
|
||||
## Debug with LLM Proxy
|
||||
|
||||
If you are dealing with LLM optimization like Reinforcement Learning, we generally recommend using an online stable LLM service for your debugging purposes, like `openai/gpt-4.1-nano`. After the debugging is done, you can switch to a local training endpoint.
|
||||
|
||||
However, if you want to use a local LLM features like [getting the token IDs](../deep-dive/serving-llm.md), you can also manually start a local vLLM server by:
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen2.5-0.5B-Instruct --port 8080
|
||||
```
|
||||
|
||||
Then start the LLM proxy via the following script:
|
||||
|
||||
```python
|
||||
import agentlightning as agl
|
||||
|
||||
llm_proxy = agl.LLMProxy(
|
||||
port=8081,
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "Qwen/Qwen2.5-0.5B-Instruct",
|
||||
"litellm_params": {
|
||||
"model": "hosted_vllm/Qwen/Qwen2.5-0.5B-Instruct",
|
||||
"api_base": "http://localhost:8080/v1",
|
||||
},
|
||||
}
|
||||
],
|
||||
store=agl.InMemoryLightningStore(),
|
||||
)
|
||||
|
||||
llm_proxy.start()
|
||||
time.sleep(1000000)
|
||||
```
|
||||
|
||||
You can now use the LLM proxy by specifying environment variables:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_BASE=http://localhost:8081/v1
|
||||
export OPENAI_API_KEY=dummy
|
||||
```
|
||||
|
||||
You might see warnings about `Missing or invalid rollout_id, attempt_id, or sequence_id` in the LLM proxy logs. This is fine because you don't have a rollout and attempt yet when you are debugging. When you started the training, the algorithm will create the rollouts for you and the warnings will go away.
|
||||
|
||||
## Hook into Runner's Lifecycle
|
||||
|
||||
[`Runner.run_context`][agentlightning.Runner.run_context] accepts a `hooks` argument so you can observe or augment lifecycle events without editing your agent. Hooks subclass [`Hook`][agentlightning.Hook] and can respond to four asynchronous callbacks: [`on_trace_start`][agentlightning.Hook.on_trace_start], [`on_rollout_start`][agentlightning.Hook.on_rollout_start], [`on_rollout_end`][agentlightning.Hook.on_rollout_end], and [`on_trace_end`][agentlightning.Hook.on_trace_end]. This is useful for:
|
||||
|
||||
@@ -141,7 +141,7 @@ Return only a number between 0 and 1. No text, punctuation, or explanation."""
|
||||
try:
|
||||
content = result.choices[0].message.content
|
||||
if content is None:
|
||||
console.print(f"[bold blue][Judge][/bold blue] Judge retured no content: {result}")
|
||||
console.print(f"[bold blue][Judge][/bold blue] Judge returned no content: {result}")
|
||||
return 0.0
|
||||
score = float(content)
|
||||
console.print(f"[bold blue][Judge][/bold blue] Judge returned score: {score}")
|
||||
|
||||
@@ -40,7 +40,7 @@ import agentlightning as agl
|
||||
|
||||
|
||||
def verl_default_config() -> Dict[str, Any]:
|
||||
return {
|
||||
config = {
|
||||
"algorithm": {
|
||||
"adv_estimator": "grpo",
|
||||
"use_kl_in_reward": False,
|
||||
@@ -58,6 +58,12 @@ def verl_default_config() -> Dict[str, Any]:
|
||||
"multi_turn": {"format": "hermes"},
|
||||
"name": "vllm",
|
||||
"gpu_memory_utilization": 0.6,
|
||||
"engine_kwargs": {
|
||||
"vllm": {
|
||||
"enable_auto_tool_choice": True,
|
||||
"tool_call_parser": "hermes",
|
||||
}
|
||||
},
|
||||
},
|
||||
"actor": {
|
||||
"ppo_mini_batch_size": 32,
|
||||
@@ -96,6 +102,7 @@ def verl_default_config() -> Dict[str, Any]:
|
||||
"total_epochs": 2,
|
||||
},
|
||||
}
|
||||
return config
|
||||
|
||||
|
||||
def train(
|
||||
|
||||
@@ -136,7 +136,7 @@ def replace_cur_year(query: str) -> str:
|
||||
def get_cursor_from_path(sqlite_path: str):
|
||||
try:
|
||||
if not os.path.exists(sqlite_path):
|
||||
print("Openning a new connection %s" % sqlite_path)
|
||||
print("Opening a new connection %s" % sqlite_path)
|
||||
connection = sqlite3.connect(sqlite_path)
|
||||
except Exception as e:
|
||||
print(sqlite_path)
|
||||
|
||||
@@ -52,6 +52,12 @@ RL_TRAINING_CONFIG: Dict[str, Any] = {
|
||||
"multi_turn": {"format": "hermes"},
|
||||
"name": "vllm",
|
||||
"gpu_memory_utilization": 0.8,
|
||||
"engine_kwargs": {
|
||||
"vllm": {
|
||||
"enable_auto_tool_choice": True,
|
||||
"tool_call_parser": "hermes",
|
||||
}
|
||||
},
|
||||
},
|
||||
"actor": {
|
||||
"ppo_mini_batch_size": 32,
|
||||
@@ -139,6 +145,7 @@ def config_train_llama() -> Dict[str, Any]:
|
||||
|
||||
config = deepcopy(RL_TRAINING_CONFIG)
|
||||
config["actor_rollout_ref"]["rollout"]["multi_turn"]["format"] = "llama3_json"
|
||||
config["actor_rollout_ref"]["rollout"]["engine_kwargs"]["vllm"]["tool_call_parser"] = "llama3_json"
|
||||
config["actor_rollout_ref"]["model"]["path"] = "meta-llama/Llama-3.2-1B-Instruct"
|
||||
return config
|
||||
|
||||
|
||||
@@ -123,6 +123,8 @@ nav:
|
||||
- Trainer: reference/trainer.md
|
||||
- Types: reference/types.md
|
||||
- Internal: reference/internal.md
|
||||
- Miscellaneous:
|
||||
- Changelog: changelog.md
|
||||
|
||||
extra_css:
|
||||
- https://unpkg.com/katex@0/dist/katex.min.css
|
||||
|
||||
+8
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agentlightning"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
description = "Agent-lightning is the absolute trainer to light up AI agents."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -30,8 +30,8 @@ apo = [
|
||||
# It's not recommended to use agentlightning[verl] to install VERL and its dependencies.
|
||||
# though it's listed here for completeness.
|
||||
verl = [
|
||||
"verl>=0.5.0,<0.6.0",
|
||||
"vllm>=0.8.4,<0.11.0",
|
||||
"verl>=0.5.0",
|
||||
"vllm>=0.8.4,<0.11.0", # Due to interface change of ExternalZeroMQDistributedExecutor
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -112,12 +112,14 @@ torch-gpu-stable = [
|
||||
{include-group = "torch-cu128"},
|
||||
"flash-attn>=2.8.3",
|
||||
"tensordict>=0.9.1",
|
||||
"verl>=0.6.0",
|
||||
]
|
||||
# Use this instead of --group torch-legacy --group torch-gpu
|
||||
torch-gpu-legacy = [
|
||||
{include-group = "torch-legacy"},
|
||||
{include-group = "torch-cu128"},
|
||||
"flash-attn==2.8.1",
|
||||
"verl==0.5.0",
|
||||
]
|
||||
|
||||
# For the TRL/Unsloth example.
|
||||
@@ -154,6 +156,9 @@ autogen = [
|
||||
]
|
||||
openai-agents = [
|
||||
"openai-agents",
|
||||
"openai<2.7.0",
|
||||
# Compatibility issues with the latest version of OpenAI, temporarily fixed version
|
||||
# Issue: https://github.com/openai/openai-agents-python/issues/2038
|
||||
"mcp",
|
||||
]
|
||||
anthropic = [
|
||||
|
||||
@@ -76,6 +76,40 @@ async def server_client() -> AsyncGenerator[Tuple[LightningStoreServer, Lightnin
|
||||
await server.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_start_rejects_port_conflict() -> None:
|
||||
"""Ensure startup fails loudly when the port is already owned by another store."""
|
||||
store_a = InMemoryLightningStore()
|
||||
port = _get_free_port()
|
||||
server_a = LightningStoreServer(store_a, "127.0.0.1", port)
|
||||
await server_a.start()
|
||||
|
||||
store_b = InMemoryLightningStore()
|
||||
server_b = LightningStoreServer(store_b, "127.0.0.1", port)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Another process may already be using this port"):
|
||||
await server_b.start()
|
||||
|
||||
await server_a.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_forever_rejects_port_conflict() -> None:
|
||||
"""Ensure run_forever also reports port conflicts with the friendly message."""
|
||||
store_a = InMemoryLightningStore()
|
||||
port = _get_free_port()
|
||||
server_a = LightningStoreServer(store_a, "127.0.0.1", port)
|
||||
await server_a.start()
|
||||
|
||||
store_b = InMemoryLightningStore()
|
||||
server_b = LightningStoreServer(store_b, "127.0.0.1", port)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Another process may already be using this port"):
|
||||
await server_b.run_forever()
|
||||
|
||||
await server_a.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_resources_via_server(server_client: Tuple[LightningStoreServer, LightningStoreClient]) -> None:
|
||||
"""Test that add_resources works correctly via server."""
|
||||
@@ -484,59 +518,69 @@ async def test_subprocess_client_operations_work_but_direct_store_access_fails()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"status,endpoint,make_app_error",
|
||||
[
|
||||
(400, "/enqueue_rollout", True), # server-marked app error -> 400 -> no retry
|
||||
(404, "/update_rollout", False), # non-408 4xx -> no retry
|
||||
],
|
||||
)
|
||||
async def test_no_retry_on_4xx_application_and_non408(
|
||||
@pytest.mark.flaky(reruns=3, reruns_delay=2)
|
||||
async def test_retry_on_400_application_error(
|
||||
server_client: Tuple[LightningStoreServer, LightningStoreClient],
|
||||
monkeypatch: MonkeyPatch,
|
||||
status: int,
|
||||
endpoint: str,
|
||||
make_app_error: bool,
|
||||
) -> None:
|
||||
"""Test that client retries on app-side 400 that becomes a 500 due to server exception handling."""
|
||||
server, client = server_client
|
||||
|
||||
if make_app_error:
|
||||
# Force app-side exception so server returns 400 via exception handler.
|
||||
call_count = {"n": 0}
|
||||
original = server.store.enqueue_rollout
|
||||
# Force app-side exception so server returns 400 via exception handler.
|
||||
call_count = {"n": 0}
|
||||
original = server.store.enqueue_rollout
|
||||
|
||||
async def boom(*args: Any, **kwargs: Any) -> Any:
|
||||
call_count["n"] += 1
|
||||
raise RuntimeError("synthetic app error")
|
||||
async def boom(*args: Any, **kwargs: Any) -> Any:
|
||||
call_count["n"] += 1
|
||||
raise RuntimeError("synthetic app error")
|
||||
|
||||
monkeypatch.setattr(server.store, "enqueue_rollout", boom, raising=True)
|
||||
monkeypatch.setattr(server.store, "enqueue_rollout", boom, raising=True)
|
||||
|
||||
with pytest.raises(ClientResponseError) as ei:
|
||||
await client.enqueue_rollout(input={"origin": "should-fail"})
|
||||
assert ei.value.status == 400
|
||||
assert call_count["n"] == 1
|
||||
with pytest.raises(ClientResponseError) as ei:
|
||||
await client.enqueue_rollout(input={"origin": "should-fail"})
|
||||
|
||||
monkeypatch.setattr(server.store, "enqueue_rollout", original, raising=True)
|
||||
else:
|
||||
# Raise 404 once for /update_rollout; client must not retry.
|
||||
original_post = aiohttp.ClientSession.post
|
||||
calls = {"n": 0}
|
||||
assert ei.value.status == 400
|
||||
assert call_count["n"] == 1
|
||||
|
||||
def post_404(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any) -> MockResponse:
|
||||
if str(url).endswith(endpoint):
|
||||
calls["n"] += 1
|
||||
req_info = aiohttp.RequestInfo(
|
||||
url=URL(str(url)), method="POST", headers=cast(Any, {}), real_url=URL(str(url))
|
||||
)
|
||||
raise ClientResponseError(request_info=req_info, history=(), status=status, message="not found")
|
||||
return MockResponse(original_post(self, url, *args, **kwargs))
|
||||
# Restore original method
|
||||
monkeypatch.setattr(server.store, "enqueue_rollout", original, raising=True)
|
||||
|
||||
monkeypatch.setattr(aiohttp.ClientSession, "post", post_404, raising=True)
|
||||
|
||||
with pytest.raises(ClientResponseError) as ei:
|
||||
await client.update_rollout("nonexistent", status="running")
|
||||
assert ei.value.status == 404
|
||||
assert calls["n"] == 1
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_retry_on_non408_4xx(
|
||||
server_client: Tuple[LightningStoreServer, LightningStoreClient],
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test that client does not retry on non-408 4xx errors such as 404."""
|
||||
_, client = server_client
|
||||
|
||||
original_post = aiohttp.ClientSession.post
|
||||
calls = {"n": 0}
|
||||
|
||||
def post_404(self: aiohttp.ClientSession, url: Any, *args: Any, **kwargs: Any):
|
||||
if str(url).endswith("/update_rollout"):
|
||||
calls["n"] += 1
|
||||
req_info = aiohttp.RequestInfo(
|
||||
url=URL(str(url)),
|
||||
method="POST",
|
||||
headers=cast(Any, {}),
|
||||
real_url=URL(str(url)),
|
||||
)
|
||||
raise ClientResponseError(
|
||||
request_info=req_info,
|
||||
history=(),
|
||||
status=404,
|
||||
message="not found",
|
||||
)
|
||||
return MockResponse(original_post(self, url, *args, **kwargs))
|
||||
|
||||
monkeypatch.setattr(aiohttp.ClientSession, "post", post_404, raising=True)
|
||||
|
||||
with pytest.raises(ClientResponseError) as ei:
|
||||
await client.update_rollout("nonexistent", status="running")
|
||||
|
||||
assert ei.value.status == 404
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1639,7 +1639,7 @@ async def test_status_propagation_only_for_latest_attempt(inmemory_store: InMemo
|
||||
# Rollout status should NOT change since attempt1 is not the latest
|
||||
updated_rollout = await inmemory_store.get_rollout_by_id(rollout.rollout_id)
|
||||
assert updated_rollout is not None
|
||||
assert updated_rollout.status == "queuing" # Should remain unchanged
|
||||
assert updated_rollout.status == "preparing" # Should be status of attempt 2
|
||||
|
||||
# Update attempt3 (latest) to succeeded
|
||||
await inmemory_store.update_attempt(
|
||||
@@ -1670,7 +1670,7 @@ async def test_status_propagation_with_retry_for_latest_attempt(inmemory_store:
|
||||
|
||||
updated_rollout = await inmemory_store.get_rollout_by_id(rollout.rollout_id)
|
||||
assert updated_rollout is not None
|
||||
assert updated_rollout.status == "queuing" # Should remain unchanged
|
||||
assert updated_rollout.status == "preparing" # Should be status of attempt 2
|
||||
|
||||
# Fail attempt2 (latest) - should trigger retry since sequence_id=2 < max_attempts=3
|
||||
await inmemory_store.update_attempt(
|
||||
@@ -1708,7 +1708,7 @@ async def test_status_propagation_latest_changes_when_new_attempt_added(inmemory
|
||||
|
||||
updated_rollout = await inmemory_store.get_rollout_by_id(rollout.rollout_id)
|
||||
assert updated_rollout is not None
|
||||
assert updated_rollout.status == "succeeded" # Should remain unchanged
|
||||
assert updated_rollout.status == "preparing" # Should be the status of attempt 2
|
||||
|
||||
# Update attempt2 (now latest) to failed
|
||||
await inmemory_store.update_attempt(
|
||||
|
||||
@@ -26,6 +26,7 @@ import multiprocessing
|
||||
import os
|
||||
import pprint
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
@@ -101,12 +102,14 @@ class MockOpenAICompatibleServer:
|
||||
Now supports replaying from prompt caches.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 58000) -> None:
|
||||
def __init__(self, host: str = "127.0.0.1", port: Optional[int] = None) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._requested_port = port
|
||||
self.port: Optional[int] = port
|
||||
self.app = FastAPI()
|
||||
self.server_thread = None
|
||||
self.server = None
|
||||
self._prev_openai_base_url: Optional[str] = None
|
||||
self.prompt_caches = self._load_prompt_caches()
|
||||
self._setup_routes()
|
||||
|
||||
@@ -167,8 +170,17 @@ class MockOpenAICompatibleServer:
|
||||
return cached_response
|
||||
raise ValueError("No suitable cached response found. Please ensure the prompt caches are populated.")
|
||||
|
||||
def _resolve_port(self) -> int:
|
||||
if self._requested_port:
|
||||
return self._requested_port
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind((self.host, 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
async def __aenter__(self):
|
||||
# Start the server manually
|
||||
self.port = self._resolve_port()
|
||||
config = uvicorn.Config(self.app, host=self.host, port=self.port, log_level="error")
|
||||
self.server = uvicorn.Server(config)
|
||||
self.server_thread = threading.Thread(target=self.server.run, daemon=True)
|
||||
@@ -184,6 +196,11 @@ class MockOpenAICompatibleServer:
|
||||
if not getattr(self.server, "started", False):
|
||||
raise RuntimeError("Server failed to start within timeout")
|
||||
|
||||
# Update the module-level base URL so downstream clients use the live port.
|
||||
global OPENAI_BASE_URL
|
||||
self._prev_openai_base_url = OPENAI_BASE_URL
|
||||
OPENAI_BASE_URL = f"http://{self.host}:{self.port}/v1"
|
||||
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||
@@ -191,6 +208,10 @@ class MockOpenAICompatibleServer:
|
||||
self.server.should_exit = True
|
||||
if self.server_thread and self.server_thread.is_alive():
|
||||
self.server_thread.join(timeout=5)
|
||||
if self._prev_openai_base_url is not None:
|
||||
global OPENAI_BASE_URL
|
||||
OPENAI_BASE_URL = self._prev_openai_base_url
|
||||
self._prev_openai_base_url = None
|
||||
|
||||
|
||||
async def run_agent(agent_func: Callable[[], Any]) -> None:
|
||||
|
||||
Reference in New Issue
Block a user