Files
microsoft--agent-lightning/agentlightning/server.py
T
wehub-resource-sync 85742ab165
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
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

402 lines
16 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
"""Legacy HTTP server compatible with the original Agent Lightning protocol.
The implementation in this module predates the modern store-powered runtime and
is kept for backwards compatibility with older deployments. New applications
should migrate to the store architecture where possible.
"""
from __future__ import annotations
import asyncio
import logging
import threading
import time
import uuid
import warnings
from contextlib import asynccontextmanager
from typing import Any, Dict, List, Literal, Optional
import uvicorn
from fastapi import FastAPI, HTTPException, Path
from .types import (
GenericResponse,
NamedResources,
ResourcesUpdate,
RolloutLegacy,
Task,
TaskIfAny,
)
logger = logging.getLogger(__name__)
class ServerDataStore:
"""Async-safe container for in-memory server state.
The store tracks queued tasks, claimed tasks, uploaded rollouts, and the
currently published resources. All interactions are guarded by asyncio locks
so that the FastAPI handlers can safely run in parallel.
!!! warning "Deprecated"
[`ServerDataStore`][agentlightning.server.ServerDataStore] is part of
the legacy client/server stack. Use [`LightningStore`][agentlightning.LightningStore] instead.
"""
def __init__(self):
self._task_queue: asyncio.Queue[Task] = asyncio.Queue()
self._processing_tasks: Dict[str, Task] = {} # Currently processing tasks
self._completed_rollouts: Dict[str, RolloutLegacy] = {}
# Store for versioned resources
self._resource_versions: Dict[str, NamedResources] = {}
self._latest_resources_id: Optional[str] = None
# Locks for thread-safe access
self._results_lock = asyncio.Lock()
self._resources_lock = asyncio.Lock()
async def add_task(
self,
sample: Any,
mode: Literal["train", "val", "test"] | None = None,
resources_id: str | None = None,
metadata: Dict[str, Any] | None = None,
) -> str:
"""Enqueue a new task and return the generated rollout identifier.
Args:
sample: Payload that describes the task input.
mode: Phase in which the sample should be executed (`"train"`, `"val"`, or
`"test"`).
resources_id: Identifier of a resource bundle that the executor should
load before running the task.
metadata: Optional metadata forwarded to the executor.
Returns:
Unique rollout identifier assigned to the task.
"""
rollout_id = f"rollout-{uuid.uuid4()}"
task = Task(
rollout_id=rollout_id,
input=sample,
mode=mode,
resources_id=resources_id,
create_time=time.time(),
num_claims=0,
metadata=metadata or {},
)
await self._task_queue.put(task)
logger.info(f"Task queued: {rollout_id} (mode: {mode}, resources_id: {resources_id})")
return rollout_id
async def get_next_task(self) -> Optional[Task]:
"""Retrieve the next task from the queue without blocking.
Returns:
Next [`Task`][agentlightning.Task] ready to execute, or ``None``
when the queue is empty.
"""
try:
async with self._results_lock:
task = self._task_queue.get_nowait()
task = task.model_copy(
update={
"last_claim_time": time.time(),
"num_claims": (task.num_claims or 0) + 1,
}
)
self._processing_tasks[task.rollout_id] = task
if task.num_claims == 1:
logger.debug(f"Next task retrieved: {task.rollout_id}")
else:
logger.info(f"Task {task.rollout_id} re-claimed (attempt {task.num_claims})")
return task
except asyncio.QueueEmpty:
return None
async def update_resources(self, update: ResourcesUpdate):
"""Persist a new resource bundle and mark it as the latest version.
Args:
update: Resource payload received from a client.
"""
# TODO: evict old resources if necessary.
async with self._resources_lock:
self._resource_versions[update.resources_id] = update.resources
self._latest_resources_id = update.resources_id
logger.info(f"Resources updated. New version '{update.resources_id}' is now latest.")
async def get_resources_by_id(self, resources_id: str) -> Optional[ResourcesUpdate]:
"""Retrieve a specific resource bundle by identifier.
Args:
resources_id: Identifier that was previously published to the store.
Returns:
Matching [`ResourcesUpdate`][agentlightning.ResourcesUpdate]
instance, or ``None`` when the identifier is unknown.
"""
async with self._resources_lock:
resources = self._resource_versions.get(resources_id)
if resources:
return ResourcesUpdate(
resources_id=resources_id,
resources=resources,
create_time=time.time(),
update_time=time.time(),
version=1,
)
return None
async def get_latest_resources(self) -> Optional[ResourcesUpdate]:
"""Return the most recent resource bundle, if one exists."""
if self._latest_resources_id:
return await self.get_resources_by_id(self._latest_resources_id)
return None
async def store_rollout(self, rollout: RolloutLegacy):
"""Persist a completed rollout for later inspection.
Args:
rollout: Rollout returned by a client.
"""
async with self._results_lock:
self._processing_tasks.pop(rollout.rollout_id, None)
self._completed_rollouts[rollout.rollout_id] = rollout
logger.info(f"Rollout received and stored: {rollout.rollout_id}")
async def retrieve_rollout(self, rollout_id: str) -> Optional[RolloutLegacy]:
"""Retrieve and remove a stored rollout by identifier.
Args:
rollout_id: Identifier of the rollout to fetch.
Returns:
Stored [`RolloutLegacy`][agentlightning.RolloutLegacy], or ``None``
when the identifier is unknown.
"""
async with self._results_lock:
return self._completed_rollouts.pop(rollout_id, None)
async def retrieve_completed_rollouts(self) -> List[RolloutLegacy]:
"""Return all completed rollouts and clear the internal buffer."""
async with self._results_lock:
rollouts = list(self._completed_rollouts.values())
self._completed_rollouts.clear()
return rollouts
def get_processing_tasks(self) -> Dict[str, Task]:
"""Return a copy of currently processing tasks for timeout checking."""
return self._processing_tasks.copy()
async def requeue_task(self, task: Task):
"""Requeue a task that timed out while being processed."""
logger.warning(f"Requeuing task {task.rollout_id} after timeout (attempt {task.num_claims})")
async with self._results_lock:
# Remove from processing tasks
self._processing_tasks.pop(task.rollout_id, None)
self._task_queue.put_nowait(task)
class AgentLightningServer:
"""High-level controller for the legacy Agent Lightning FastAPI server.
The controller orchestrates server start-up, task queueing, resource updates,
and retrieval of client rollouts. It is primarily used by existing systems that
still rely on the HTTP-based workflow.
!!! warning "Deprecated"
[`AgentLightningServer`][agentlightning.server.AgentLightningServer] is part of
the legacy client/server stack. Prefer the store-based runtime for new
integrations.
"""
def __init__(self, host: str = "127.0.0.1", port: int = 8000, task_timeout_seconds: float = 300.0):
"""Initialize the controller.
Args:
host: Hostname or IP address to bind the HTTP server to.
port: TCP port exposed by the server.
task_timeout_seconds: Seconds before a claimed task is considered stale and
re-queued.
"""
warnings.warn(
"AgentLightningServer is deprecated. Please use LightningStoreServer instead.", DeprecationWarning
)
self.host = host
self.port = port
self.endpoint = f"http://{host}:{port}"
self._task_timeout_seconds = task_timeout_seconds
# Defer initialization and use event for cross-thread communication
self._store: Optional[ServerDataStore] = None
self.loop: Optional[asyncio.AbstractEventLoop] = None
self.startup_event = threading.Event()
# Create FastAPI app instance with a lifespan manager
self._app = FastAPI(lifespan=self._lifespan)
self._setup_routes()
self._uvicorn_config = uvicorn.Config(self._app, host=self.host, port=self.port, log_level="info")
self._uvicorn_server = uvicorn.Server(self._uvicorn_config)
# --- ADDED: Lifespan context manager ---
@asynccontextmanager
async def _lifespan(self, app: FastAPI):
"""Manage server start-up and shutdown within the event loop."""
logger.info("Server is starting up...")
self.loop = asyncio.get_running_loop()
self._store = ServerDataStore() # Initialize data store here
self.startup_event.set() # Signal that the server is ready
yield
logger.info("Server is shutting down.")
self._store = None
self.startup_event.clear() # Clear the startup event
self.loop = None
async def _check_and_requeue_stale_tasks(self):
"""Check for stale tasks and requeue them when they exceed the timeout."""
current_time = time.time()
# Ensure store is initialized before checking
if not self._store:
return
processing_tasks = self._store.get_processing_tasks()
for _, task in processing_tasks.items():
if task.last_claim_time and current_time - task.last_claim_time > self._task_timeout_seconds:
await self._store.requeue_task(task)
logger.warning(
f"Task {task.rollout_id} timed out after {self._task_timeout_seconds}s, requeued (attempt {task.num_claims})"
)
def _setup_routes(self):
"""Configure the FastAPI routes that make up the legacy HTTP API."""
@self._app.get("/task", response_model=TaskIfAny)
async def next_task() -> TaskIfAny: # type: ignore
"""Provide the next available task to a client."""
await self._check_and_requeue_stale_tasks()
if not self._store:
return TaskIfAny(is_available=False)
task = await self._store.get_next_task()
if task:
logger.debug(f"Serving task {task.rollout_id} to a client.")
return TaskIfAny(is_available=True, task=task)
else:
logger.debug("No task available for client.")
return TaskIfAny(is_available=False)
@self._app.get("/resources/latest", response_model=ResourcesUpdate)
async def fetch_latest_resources() -> ResourcesUpdate: # type: ignore
"""Return the most recent resource bundle published to the server."""
if not self._store:
raise HTTPException(status_code=503, detail="Server not fully initialized.")
resources_update = await self._store.get_latest_resources()
if not resources_update:
raise HTTPException(status_code=404, detail="No resources have been set on the server.")
logger.debug(f"Serving latest resources '{resources_update.resources_id}' to a client.")
return resources_update
@self._app.get("/resources/{resource_id}", response_model=ResourcesUpdate)
async def fetch_resources_by_id( # type: ignore
resource_id: str = Path(..., description="The unique identifier for the resource version.")
) -> ResourcesUpdate:
"""Return a specific version of resources by identifier."""
if not self._store:
raise HTTPException(status_code=503, detail="Server not fully initialized.")
resources_update = await self._store.get_resources_by_id(resource_id)
if not resources_update:
raise HTTPException(status_code=404, detail=f"Resource ID '{resource_id}' not found.")
logger.debug(f"Serving resources for ID '{resource_id}' to a client.")
return resources_update
@self._app.post("/rollout", response_model=GenericResponse)
async def post_rollout(payload: RolloutLegacy) -> GenericResponse: # type: ignore
"""Persist the rollout reported by a client."""
if not self._store:
raise HTTPException(status_code=503, detail="Server not fully initialized.")
await self._store.store_rollout(payload)
return GenericResponse(
status="ok",
message=f"Rollout {payload.rollout_id} received and stored.",
)
async def start(self):
"""Start the FastAPI server in the background."""
logger.info(f"Starting server at {self.endpoint}")
asyncio.create_task(self._uvicorn_server.serve())
await asyncio.sleep(1) # Allow time for server to start up.
async def stop(self):
"""Stop the FastAPI server and wait for a graceful shutdown."""
if self._uvicorn_server.started:
logger.info("Stopping server...")
self._uvicorn_server.should_exit = True
await asyncio.sleep(1) # Allow time for graceful shutdown.
logger.info("Server stopped.")
async def run_forever(self):
"""Run the server indefinitely until `stop()` is invoked."""
await self._uvicorn_server.serve()
async def queue_task(
self,
sample: Any,
mode: Literal["train", "val", "test"] | None = None,
resources_id: str | None = None,
metadata: Dict[str, Any] | None = None,
) -> str:
"""Add a task to the queue for a client to process."""
if not self._store:
raise RuntimeError("Store not initialized. The server may not be running.")
return await self._store.add_task(sample, mode=mode, resources_id=resources_id, metadata=metadata)
async def update_resources(self, resources: NamedResources) -> str:
"""Publish a new resource bundle and return its generated identifier."""
if not self._store:
raise RuntimeError("Store not initialized. The server may not be running.")
resources_id = f"res-{uuid.uuid4()}"
update = ResourcesUpdate(
resources_id=resources_id, resources=resources, create_time=time.time(), update_time=time.time(), version=1
)
await self._store.update_resources(update)
return resources_id
async def get_completed_rollout(self, rollout_id: str) -> Optional[RolloutLegacy]:
"""Retrieve a specific completed rollout by identifier."""
if not self._store:
raise RuntimeError("Store not initialized. The server may not be running.")
return await self._store.retrieve_rollout(rollout_id)
async def poll_completed_rollout(self, rollout_id: str, timeout: Optional[float] = None) -> Optional[RolloutLegacy]:
"""Poll for a completed rollout until it becomes available or a timeout expires.
Args:
rollout_id: Identifier of the rollout to wait for.
timeout: Maximum number of seconds to wait. ``None`` waits indefinitely.
Returns:
Retrieved rollout, or ``None`` when the timeout is reached without success.
"""
start_time = time.time()
while True:
rollout = await self.get_completed_rollout(rollout_id)
if rollout:
return rollout
if timeout and (time.time() - start_time) >= timeout:
return None
await asyncio.sleep(1)
async def retrieve_completed_rollouts(self) -> List[RolloutLegacy]:
"""Return every completed rollout and clear the internal buffer."""
if not self._store:
raise RuntimeError("Store not initialized. The server may not be running.")
return await self._store.retrieve_completed_rollouts()