Compare commits

...

35 Commits

Author SHA1 Message Date
Yuge Zhang bb6f1594a8 minor fix 2025-12-13 12:47:53 +08:00
Yuge Zhang af28550701 move out search r1 2025-12-13 12:44:47 +08:00
Yuge Zhang 79df678ac1 Move search-r1 example 2025-12-13 12:41:44 +08:00
Yuge Zhang 05cc455cb1 fix tests 2025-12-13 12:37:20 +08:00
Yuge Zhang 93f2177639 Fix script invocation in legacy 2025-12-13 12:32:12 +08:00
jinghuan-Chen a6078caa6c fix TraceTree/match_rewards assign_to elements. (#403) 2025-12-13 00:25:54 +08:00
Yuge Zhang f1a8072546 Add ChartQA to catalog (#409) 2025-12-12 23:39:41 +08:00
Totoluo 60f9955606 Multi-modal example: ChartQA (#379)
Co-authored-by: Totoluo <52833580+Yingluo-momo@users.noreply.github.com>
Co-authored-by: Yuge Zhang <scottyugochang@gmail.com>
2025-12-12 17:22:35 +08:00
Yuge Zhang 1d199b21c7 Support customizing trainer and daemon in VERL (#407) 2025-12-12 13:31:30 +08:00
Yuge Zhang 5f62ecb6f4 Split vllm 0.10.2 from 0.11.0 (#394) 2025-12-12 12:10:53 +08:00
Yuge Zhang 1948c2ba6d Sunset HTTP tracer and Refactor tests (#402) 2025-12-11 23:57:23 +08:00
Yuge Zhang 1e36e660b1 Support with_llm_proxy and with_store in algorithms (#398) 2025-12-11 16:50:42 +08:00
Yuge Zhang 267b9936bb Support image urls export in TracerTraceToTriplets (#400) 2025-12-11 16:47:28 +08:00
Wang Zilong 14714ded2b add youtu-agent in community projects (#399) 2025-12-11 15:40:49 +08:00
Yuge Zhang c3f5cc7a39 Initialize contribution area (#396) 2025-12-10 23:46:06 +08:00
Yuge Zhang ee0fffd3a2 Upgrade tinker dependency (#393) 2025-12-10 23:23:43 +08:00
Yuge Zhang 94d1cd780e Store Benchmark - Part 6 (#388) 2025-12-10 21:11:29 +08:00
Vasu bbd5c2a30a fix: handle ref_in_actor flag for LoRA compatibility with verl 0.6.0 (#386) 2025-12-10 18:30:38 +08:00
Yuge Zhang c6f4e6c283 Add playground workflow (#391) 2025-12-10 18:27:21 +08:00
Ni Hao 8c504518bb add weave tracer (#277) 2025-12-09 18:59:11 +08:00
etsplz 337cce7fdc Fix redundant cancel tracebacks on ctrl+c (issue #343) (#370) 2025-12-08 17:18:07 +08:00
Yuge Zhang 42c63d7a01 Store Benchmark - Part 5 (#380) 2025-12-08 12:43:18 +08:00
Yuge Zhang 5ecd23792d Fix trainer dev warning (#378) 2025-12-08 00:43:56 +08:00
Yuge Zhang ad89e173e1 Fix TracesTable story (#375) 2025-12-06 14:25:09 +08:00
Yuge Zhang feebaec24c Add AGENTS.md (#374) 2025-12-06 13:16:26 +08:00
Copilot 4adf4e3ea4 Fix sequence ID sorting in traces table (#371)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ultmaster <8463288+ultmaster@users.noreply.github.com>
2025-12-06 12:31:42 +08:00
Yuge Zhang 0294eb5d32 GitHub Actions for RAG example (#357) 2025-12-06 12:04:42 +08:00
Leonardo Pinheiro f9fe772e10 Update langchain to 1.x (#364) 2025-12-05 21:35:39 +08:00
Yuge Zhang 9f8a25ffdc Store Benchmark - Part 4 (#356) 2025-12-05 12:00:11 +08:00
Yuge Zhang 3082ac0ee0 Centralized metrics helper (#368) 2025-12-05 08:47:10 +08:00
Yuge Zhang 56e5c7ce62 Operation emitter (#359) 2025-12-04 15:16:29 +08:00
Yuge Zhang 21892cc6d3 Skip vllm 0.12.0 (#361) 2025-12-04 14:21:23 +08:00
Wang Zilong 34811cb454 Update RAG example to v0.2.x (#349) 2025-12-03 15:46:57 +08:00
Yuge Zhang 003b8c6f83 Store Benchmark - Part 3 (#344) 2025-12-03 01:10:38 +08:00
Yuge Zhang 63b6d42669 Claude Code Example README update (#348) 2025-12-02 01:01:30 +08:00
148 changed files with 17958 additions and 7400 deletions
+29
View File
@@ -0,0 +1,29 @@
name: Badge - ChartQA
on:
workflow_run:
workflows:
- Examples - ChartQA
types: [completed]
workflow_dispatch:
permissions:
actions: read
contents: read
jobs:
badge:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'main') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const badgeAggregation = require('./scripts/badge_aggregation.js');
const dependencies = [
{ workflow: 'examples-chartqa.yml', label: 'chartqa', variants: ['stable'] },
];
await badgeAggregation({ github, context, core, dependencies });
+29
View File
@@ -0,0 +1,29 @@
name: Badge - Claude Code
on:
workflow_run:
workflows:
- Examples - Claude Code
types: [completed]
workflow_dispatch:
permissions:
actions: read
contents: read
jobs:
badge:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'main') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const badgeAggregation = require('./scripts/badge_aggregation.js');
const dependencies = [
{ workflow: 'examples-claude-code.yml', label: 'claude-code', variants: ['stable'] },
];
await badgeAggregation({ github, context, core, dependencies });
+6
View File
@@ -9,6 +9,9 @@ on:
- Examples - Unsloth
- Examples - Tinker
- Examples - Azure
- Examples - Claude Code
- Examples - RAG
- Examples - ChartQA
types: [completed]
workflow_dispatch:
@@ -35,5 +38,8 @@ jobs:
{ workflow: 'examples-unsloth.yml', label: 'examples-unsloth.stable', variants: ['stable'] },
{ workflow: 'examples-tinker.yml', label: 'examples-tinker.stable', variants: ['stable'] },
{ workflow: 'examples-azure.yml', label: 'examples-azure.stable', variants: ['stable'] },
{ workflow: 'examples-claude-code.yml', label: 'examples-claude-code.stable', variants: ['stable'] },
{ workflow: 'examples-rag.yml', label: 'examples-rag.stable', variants: ['stable'] },
{ workflow: 'examples-chartqa.yml', label: 'examples-chartqa.stable', variants: ['stable'] },
];
await badgeAggregation({ github, context, core, dependencies });
+4
View File
@@ -7,6 +7,8 @@ on:
- Examples - Spider
- Examples - APO
- Examples - Unsloth
- Examples - RAG
- Examples - Claude Code
- GPU Test
types: [completed]
@@ -32,6 +34,8 @@ jobs:
{ workflow: 'examples-spider.yml', label: 'spider.latest', variants: ['latest'] },
{ workflow: 'examples-apo.yml', label: 'apo.latest', variants: ['latest'] },
{ workflow: 'examples-unsloth.yml', label: 'unsloth.latest', variants: ['latest'] },
{ workflow: 'examples-claude-code.yml', label: 'claude-code.latest', variants: ['latest'] },
{ workflow: 'examples-rag.yml', label: 'rag.latest', variants: ['latest'] },
{ workflow: 'tests-full.yml', label: 'tests-full.latest', variants: ['latest'] },
];
await badgeAggregation({ github, context, core, dependencies });
+29
View File
@@ -0,0 +1,29 @@
name: Badge - RAG
on:
workflow_run:
workflows:
- Examples - RAG
types: [completed]
workflow_dispatch:
permissions:
actions: read
contents: read
jobs:
badge:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'main') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const badgeAggregation = require('./scripts/badge_aggregation.js');
const dependencies = [
{ workflow: 'examples-rag.yml', label: 'rag', variants: ['legacy', 'stable'] },
];
await badgeAggregation({ github, context, core, dependencies });
+1 -1
View File
@@ -24,6 +24,6 @@ jobs:
script: |
const badgeAggregation = require('./scripts/badge_aggregation.js');
const dependencies = [
{ workflow: 'examples-spider.yml', label: 'spider', variants: ['stable', 'legacy'] },
{ workflow: 'examples-spider.yml', label: 'spider', variants: ['stable'] },
];
await badgeAggregation({ github, context, core, dependencies });
+272 -23
View File
@@ -6,9 +6,9 @@ on:
jobs:
benchmark:
name: Benchmark (${{ matrix.backend.id }}, ${{ matrix.scenario.display }})
runs-on: [self-hosted, 1ES.Pool=agl-runner-cpu]
timeout-minutes: 60
name: ${{ matrix.workload.kind }} (${{ matrix.backend.id }}, ${{ matrix.workload.display }})
runs-on: ${{ matrix.workload.runner }}
timeout-minutes: ${{ matrix.workload.timeout }}
strategy:
fail-fast: false
matrix:
@@ -17,10 +17,15 @@ jobs:
compose_file: compose.prometheus-memory-store.yml
- id: mongo
compose_file: compose.prometheus-mongo-store.yml
scenario:
- id: minimal-production
workload:
- id: scenario-minimal-scale
display: Minimal production scale
kind: scenario
store_workers: 4
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu
timeout: 60
args: >-
--mode batch
--total-tasks 4096
@@ -28,9 +33,14 @@ jobs:
--n-runners 32
--max-rounds 6
--sleep-seconds 0.5
- id: medium-production
- id: scenario-medium-scale
display: Medium production scale
kind: scenario
store_workers: 16
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu
timeout: 60
args: >-
--mode batch
--total-tasks 10000
@@ -38,9 +48,29 @@ jobs:
--n-runners 100
--max-rounds 10
--sleep-seconds 0.1
- id: large-batch
- id: scenario-midhigh-scale
display: Mid-high production scale
kind: scenario
store_workers: 24
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu
timeout: 60
args: >-
--mode batch
--total-tasks 20000
--batch-size 2048
--n-runners 256
--max-rounds 8
--sleep-seconds 0.1
- id: scenario-large-batch
display: Large batch waves
kind: scenario
store_workers: 32
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu
timeout: 60
args: >-
--mode batch
--total-tasks 100000
@@ -48,9 +78,14 @@ jobs:
--n-runners 256
--max-rounds 6
--sleep-seconds 0.1
- id: long-queues
- id: scenario-long-queues
display: Long rollout queues
kind: scenario
store_workers: 32
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu
timeout: 60
args: >-
--mode batch_partial
--total-tasks 100000
@@ -59,9 +94,14 @@ jobs:
--remaining-tasks 4096
--max-rounds 4
--sleep-seconds 0.1
- id: high-concurrency
- id: scenario-high-concurrency
display: High-throughput concurrent requests
kind: scenario
store_workers: 32
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu
timeout: 60
args: >-
--mode single
--total-tasks 100000
@@ -69,9 +109,14 @@ jobs:
--n-runners 256
--max-rounds 2
--sleep-seconds 0.1
- id: heavy-traces
- id: scenario-heavy-traces
display: Heavy rollouts with deep traces
kind: scenario
store_workers: 64
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu
timeout: 60
args: >-
--mode batch_partial
--total-tasks 10000
@@ -80,15 +125,63 @@ jobs:
--n-runners 512
--max-rounds 20
--sleep-seconds 1.0
- id: micro-worker
display: Update worker
kind: micro
store_workers: 8
runner: ubuntu-latest
timeout: 30
cli: worker
- id: micro-dequeue-empty
display: Dequeue empty
kind: micro
store_workers: 8
runner: ubuntu-latest
timeout: 30
cli: dequeue-empty
- id: micro-rollout
display: Rollout + span
kind: micro
store_workers: 8
runner: ubuntu-latest
timeout: 30
cli: rollout
- id: micro-dequeue-update-attempt
display: Dequeue + update attempt
kind: micro
store_workers: 8
runner: ubuntu-latest
timeout: 30
cli: dequeue-update-attempt
- id: micro-dequeue-only
display: Dequeue only
kind: micro
store_workers: 8
runner: ubuntu-latest
timeout: 30
cli: dequeue-only
- id: micro-metrics
display: Multi-metric fan-out
kind: micro
store_workers: 8
runner: ubuntu-latest
timeout: 15
cli: metrics
env:
STORE_URL: http://localhost:4747
STORE_API_URL: http://localhost:4747/v1/agl
PROM_URL: http://localhost:9090
SCENARIO_ID: ${{ matrix.scenario.id }}
WORKLOAD_KIND: ${{ matrix.workload.kind }}
WORKLOAD_ID: ${{ matrix.workload.id }}
BACKEND_ID: ${{ matrix.backend.id }}
ARTIFACT_DIR: artifacts/${{ matrix.scenario.id }}-${{ matrix.backend.id }}
ARTIFACT_DIR: ${{ format('artifacts/{0}-{1}', matrix.workload.id, matrix.backend.id) }}
COMPOSE_FILE: ${{ matrix.backend.compose_file }}
AGL_STORE_N_WORKERS: ${{ matrix.scenario.store_workers }}
AGL_STORE_N_WORKERS: ${{ matrix.workload.store_workers }}
ANALYSIS_FILE: ${{ format('analysis-{0}.log', matrix.workload.id) }}
SUMMARY_FILE: ${{ format('summary-{0}.log', matrix.workload.id) }}
PROM_ARCHIVE_BASENAME: ${{ format('prometheus-{0}-{1}', matrix.workload.id, matrix.backend.id) }}
ARTIFACT_NAME: ${{ format('{0}-{1}', matrix.workload.id, matrix.backend.id) }}
steps:
- uses: actions/checkout@v4
@@ -122,37 +215,65 @@ jobs:
set -euo pipefail
for attempt in {1..60}; do
if curl -fsS "$STORE_API_URL/health" >/dev/null 2>&1; then
sleep 1
curl -fsS "$STORE_API_URL/rollouts" # Warm up the scraper
sleep 15 # Allow some time for the baseline metrics to be established
exit 0
fi
sleep 1
done
echo "Store did not become ready in time" >&2
# show logs for debugging
cd docker && docker compose -f "$COMPOSE_FILE" logs app
exit 1
- name: Prepare artifact directory
run: mkdir -p "$ARTIFACT_DIR"
- name: Record benchmark start
- name: Record workload start
run: echo "BENCHMARK_START=$(date -u +%FT%TZ)" >> "$GITHUB_ENV"
- name: Run ${{ matrix.scenario.display }} workload
- name: (Scenario) Run ${{ matrix.workload.display }} workload
if: ${{ matrix.workload.kind == 'scenario' }}
run: |
set -euo pipefail
uv run --locked --no-sync python -m tests.benchmark.benchmark_store \
--store-url "$STORE_URL" \
${{ matrix.scenario.args }}
${{ matrix.workload.args }}
- name: Record benchmark end
- name: (Micro) Run ${{ matrix.workload.display }}
if: ${{ matrix.workload.kind == 'micro' }}
run: |
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
uv run --locked --no-sync python -m tests.benchmark.micro_benchmark \
--store-url "$STORE_URL" \
--summary-file "$ARTIFACT_DIR/$SUMMARY_FILE" \
"${{ matrix.workload.cli }}" | tee "$ARTIFACT_DIR/${{ matrix.workload.id }}.txt"
- name: Record workload end
if: ${{ always() }}
run: echo "BENCHMARK_END=$(date -u +%FT%TZ)" >> "$GITHUB_ENV"
- name: Run benchmark analysis
- name: Show micro benchmark summary
if: ${{ always() && matrix.workload.kind == 'micro' }}
run: |
set -euo pipefail
summary_file="$ARTIFACT_DIR/$SUMMARY_FILE"
if [ -f "$summary_file" ]; then
echo "Micro benchmark summary ($WORKLOAD_ID/$BACKEND_ID):"
cat "$summary_file"
else
echo "Summary file not found: $summary_file"
fi
- name: Run workload analysis
if: ${{ always() }}
run: |
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
if [ -z "${BENCHMARK_START:-}" ] || [ -z "${BENCHMARK_END:-}" ]; then
echo "Analysis skipped: benchmark window not recorded." > "$ARTIFACT_DIR/analysis.txt"
echo "Analysis skipped: benchmark window not recorded." > "$ARTIFACT_DIR/$ANALYSIS_FILE"
exit 1
fi
uv run --locked --no-sync python -m tests.benchmark.analysis \
@@ -160,7 +281,22 @@ jobs:
--store-url "$STORE_API_URL" \
--start "$BENCHMARK_START" \
--end "$BENCHMARK_END" \
| tee "$ARTIFACT_DIR/analysis.txt"
| tee "$ARTIFACT_DIR/$ANALYSIS_FILE"
- name: Collect docker logs
if: ${{ always() }}
run: |
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
cd docker
readarray -t services < <(docker compose -f "$COMPOSE_FILE" config --services)
if [ "${#services[@]}" -eq 0 ]; then
echo "No services defined in compose file."
exit 0
fi
for service in "${services[@]}"; do
docker compose -f "$COMPOSE_FILE" logs "$service" > "../$ARTIFACT_DIR/docker-${service}-${WORKLOAD_ID}-${BACKEND_ID}.log" || true
done
- name: Stop ${{ matrix.backend.id }} Prometheus stack
if: ${{ always() }}
@@ -175,13 +311,126 @@ jobs:
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
if [ -d docker/data/prometheus ]; then
tar -C docker/data -czf "$ARTIFACT_DIR/prometheus-${SCENARIO_ID}-${BACKEND_ID}.tar.gz" prometheus
tar -C docker/data -czf "$ARTIFACT_DIR/${PROM_ARCHIVE_BASENAME}.tar.gz" prometheus
fi
- name: Upload benchmark artifacts
- name: Upload workload artifacts
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: benchmark-${{ matrix.scenario.id }}-${{ matrix.backend.id }}
name: ${{ env.ARTIFACT_NAME }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: error
collection-benchmarks:
name: collection (${{ matrix.backend.id }}, ${{ matrix.workload.id }})
runs-on: ${{ matrix.backend.runner }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
backend:
- id: memory
needs_mongo: false
runner: ubuntu-latest
- id: mongo
needs_mongo: true
runner: ubuntu-latest
workload:
- id: high-insert
total_tasks: 100000
concurrency: 2048
type: insert
- id: medium-insert
total_tasks: 100000
concurrency: 128
type: insert
- id: low-insert
total_tasks: 100000
concurrency: 4
type: insert
- id: high-dequeue
total_tasks: 100000
concurrency: 2048
type: dequeue
- id: medium-dequeue
total_tasks: 100000
concurrency: 128
type: dequeue
- id: low-dequeue
total_tasks: 100000
concurrency: 4
type: dequeue
env:
ARTIFACT_DIR: ${{ format('artifacts/{0}-{1}', matrix.backend.id, matrix.workload.id) }}
SUMMARY_FILE: ${{ format('artifacts/{0}-{1}/summary-{0}-{1}.jsonl', matrix.backend.id, matrix.workload.id) }}
ARTIFACT_NAME: ${{ format('collections-{0}-{1}', matrix.backend.id, matrix.workload.id) }}
MONGO_URI: mongodb://localhost:27017/?replicaSet=rs0
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
python-version: '3.12'
- name: Sync dependencies
run: uv sync --frozen --extra mongo --group core-stable --group dev
- name: Launch MongoDB
if: ${{ matrix.backend.needs_mongo }}
run: |
set -euo pipefail
cd docker
docker compose -f compose.mongo.yml down -v || true
docker compose -f compose.mongo.yml up -d --quiet-pull
for attempt in {1..60}; do
if docker compose -f compose.mongo.yml exec -T mongo mongosh --quiet --eval 'db.runCommand({ping:1})' >/dev/null 2>&1; then
exit 0
fi
sleep 2
done
echo "MongoDB did not become ready in time" >&2
docker compose -f compose.mongo.yml logs mongo
exit 1
- name: Run collection benchmark
run: |
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
echo "Running collection benchmark (backend=${{ matrix.backend.id }}, workload=${{ matrix.workload.id }})"
uv run --locked --no-sync python -m tests.benchmark.collection_benchmark \
"${{ matrix.workload.type }}" \
--backend "${{ matrix.backend.id }}" \
--total-tasks "${{ matrix.workload.total_tasks }}" \
--concurrency "${{ matrix.workload.concurrency }}" \
--task-prefix "${{ matrix.backend.id }}-${{ matrix.workload.id }}" \
--summary-file "$SUMMARY_FILE" \
--mongo-uri "$MONGO_URI" \
--mongo-database agentlightning_collection_bench
- name: Show collection benchmark summary
if: ${{ always() }}
run: |
set -euo pipefail
if [ -f "$SUMMARY_FILE" ]; then
echo "Collection benchmark summary (${{ matrix.backend.id }}):"
cat "$SUMMARY_FILE"
else
echo "Summary file not found: $SUMMARY_FILE"
fi
- name: Stop MongoDB
if: ${{ always() && matrix.backend.needs_mongo }}
run: |
set -euo pipefail
cd docker
docker compose -f compose.mongo.yml down -v || true
- name: Upload collection artifacts
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: error
+25
View File
@@ -270,6 +270,31 @@ jobs:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
- name: Training with LoRA
run: |
set -ex
source .venv/bin/activate
cd examples/calc_x
../../scripts/restart_ray.sh
sleep 5
PYTHONUNBUFFERED=1 python train_calc_agent.py --val-file data/test_mini.parquet --ci-fast --lora
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_lora
if: matrix.setup-script != 'legacy'
- name: Validate training with LoRA
run: |
set -ex
uv run scripts/validate_example_wandb.py ${{ steps.calc_x_train_lora.outputs.project_name }} ${{ steps.calc_x_train_lora.outputs.run_name }}
env:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
if: matrix.setup-script != 'legacy'
- name: Training with external store
run: |
set -euo pipefail
+168
View File
@@ -0,0 +1,168 @@
name: Examples - ChartQA
permissions:
contents: read
on:
schedule:
# Every day at 6 AM UTC+8
- cron: "0 22 * * *"
workflow_dispatch:
repository_dispatch:
types: [ci-chartqa, ci-all]
run-name: >-
${{ github.event_name == 'repository_dispatch'
&& format(
'ChartQA - PR #{0} - {1} - {2}',
github.event.client_payload.pull_number,
github.event.client_payload.ci_label,
github.event.client_payload.correlation_id
)
|| format('ChartQA - {0}', github.event_name) }}
jobs:
chartqa:
if: >
github.event_name != 'repository_dispatch' ||
github.event.action == 'ci-chartqa' ||
github.event.action == 'ci-all'
name: ChartQA (Python ${{ matrix.python-version }}, ${{ matrix.setup-script }})
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
timeout-minutes: 60
strategy:
matrix:
include:
- python-version: '3.12'
setup-script: 'stable'
fail-fast: false
steps:
- name: Check GPU status
run: nvidia-smi
- name: Check disk space
run: df -h
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.pr_ref || (github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number)) || github.ref }}
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
- name: Upgrade dependencies (latest)
run: uv lock --upgrade
if: matrix.setup-script == 'latest'
- name: Sync dependencies
run: |
uv sync --frozen --no-default-groups --extra verl \
--group dev --group experiment --group image --group langchain --group vllm-0-10-2 --group torch-gpu-stable
- name: Freeze dependencies
run: |
set -ex
uv pip freeze | tee requirements-freeze.txt
echo "UV_LOCKED=1" >> $GITHUB_ENV
echo "UV_NO_SYNC=1" >> $GITHUB_ENV
- name: Upload dependencies artifact
uses: actions/upload-artifact@v4
with:
name: dependencies-chartqa-${{ matrix.python-version }}-${{ matrix.setup-script }}
path: requirements-freeze.txt
compression-level: 0
- name: Launch LiteLLM Proxy
run: |
./scripts/litellm_run.sh
env:
AZURE_API_BASE: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_BASE }}
AZURE_API_KEY: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_KEY }}
- name: Prepare ChartQA dataset
run: |
set -euo pipefail
cd examples/chartqa
uv run gdown --fuzzy "https://drive.google.com/file/d/1fWRt9hehg8_uV7BDWSCwKTycM60JcmGN/view?usp=sharing" -O chartqa-data.zip
unzip chartqa-data.zip
rm chartqa-data.zip
shell: bash
- name: ChartQA sanity check with GPT
run: |
set -euo pipefail
cd examples/chartqa
uv run python debug_chartqa_agent.py
shell: bash
env:
OPENAI_API_BASE: http://localhost:12306/
OPENAI_API_KEY: dummy
- name: Run vLLM Server
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/chartqa
uv run --no-sync vllm serve Qwen/Qwen2-VL-2B-Instruct \
--gpu-memory-utilization 0.9 \
--max-model-len 4096 \
--allowed-local-media-path "$(pwd)/data" \
--enable-prefix-caching \
--port 8088 &
VLLM_READY=0
for i in {1..100}; do
if curl -sSf http://localhost:8088/v1/models > /dev/null 2>&1; then
echo "vLLM server is ready!"
VLLM_READY=1
break
fi
echo "Waiting for vLLM server to be ready... (${i})"
sleep 5
done
if [[ "$VLLM_READY" != "1" ]]; then
echo "vLLM server failed to start!"
exit 1
fi
- name: ChartQA sanity check with vLLM
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/chartqa
uv run python debug_chartqa_agent.py
shell: bash
env:
USE_LLM_PROXY: "1"
OPENAI_API_BASE: http://localhost:8088/v1
OPENAI_MODEL: Qwen/Qwen2-VL-2B-Instruct
- name: Stop vLLM Server
run: |
set -euo pipefail
pkill -f vllm
for i in {1..60}; do
if ! pgrep -f vllm; then
break
fi
sleep 5
done
- name: ChartQA training
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/chartqa
../../scripts/restart_ray.sh
sleep 5
PYTHONUNBUFFERED=1 python train_chartqa_agent.py ci
sleep 10
shell: bash
env:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
id: chartqa_train
- name: Validate ChartQA training
run: |
set -euo pipefail
uv run scripts/validate_example_wandb.py ${{ steps.chartqa_train.outputs.project_name }} ${{ steps.chartqa_train.outputs.run_name }}
env:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
+179
View File
@@ -0,0 +1,179 @@
name: Examples - RAG
permissions:
contents: read
on:
schedule:
# Every day at 6 AM UTC+8
- cron: '0 22 * * *'
workflow_dispatch:
repository_dispatch:
types: [ci-rag, ci-all]
run-name: >-
${{ github.event_name == 'repository_dispatch'
&& format(
'RAG - PR #{0} - {1} - {2}',
github.event.client_payload.pull_number,
github.event.client_payload.ci_label,
github.event.client_payload.correlation_id
)
|| format('RAG - {0}', github.event_name) }}
jobs:
rag:
if: >
github.event_name != 'repository_dispatch' ||
github.event.action == 'ci-rag' ||
github.event.action == 'ci-all'
name: RAG (Python ${{ matrix.python-version }}, ${{ matrix.setup-script }})
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
timeout-minutes: 60
strategy:
matrix:
include:
- python-version: '3.10'
setup-script: 'legacy'
- python-version: '3.12'
setup-script: 'stable'
- python-version: '3.13'
setup-script: 'latest'
fail-fast: false
steps:
- name: Check GPU status
run: nvidia-smi
- name: Check disk space
run: df -h
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.pr_ref || (github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number)) || github.ref }}
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
- name: Upgrade dependencies (latest)
run: uv lock --upgrade
if: matrix.setup-script == 'latest'
- name: Sync dependencies (latest)
run: |
uv sync --frozen --no-default-groups --extra verl \
--group dev --group experiment --group agents --group rag --group torch-gpu-stable
if: matrix.setup-script == 'latest'
- name: Sync dependencies (stable & legacy)
run: |
uv sync --frozen --no-default-groups --extra verl \
--group dev --group experiment --group agents --group rag --group torch-gpu-${{ matrix.setup-script }}
if: matrix.setup-script != 'latest'
- name: Freeze dependencies
run: |
set -ex
uv pip freeze | tee requirements-freeze.txt
echo "UV_LOCKED=1" >> $GITHUB_ENV
echo "UV_NO_SYNC=1" >> $GITHUB_ENV
- name: Upload dependencies artifact
uses: actions/upload-artifact@v4
with:
name: dependencies-rag-${{ matrix.python-version }}-${{ matrix.setup-script }}
path: requirements-freeze.txt
compression-level: 0
- name: Launch LiteLLM Proxy
run: |
./scripts/litellm_run.sh
env:
AZURE_API_BASE: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_BASE }}
AZURE_API_KEY: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_KEY }}
- name: Prepare RAG dataset
run: |
set -euo pipefail
cd examples/rag
mkdir -p data
uv run gdown --fuzzy "https://drive.google.com/file/d/1Pq4Ag8zVoN8gUtLu0LcBfY35Dm5zL0hq/view?usp=drive_link" -O data/dataset_tiny.parquet
uv run gdown --fuzzy "https://drive.google.com/file/d/1REXCpRLbeZu1KfWWKhIGEQe_WNHUOBkS/view?usp=drive_link" -O data/chunks_candidate_tiny.pkl
uv run gdown --fuzzy "https://drive.google.com/file/d/1f6P-h_8KSRhe5pqDHWbRQWvUhTygfZ-c/view?usp=drive_link" -O data/index_hnsw_faiss_n32e40_tiny.index
- name: Run WIKI Retriever MCP Server
run: |
set -euo pipefail
cd examples/rag
uv run python wiki_retriever_mcp.py &
for i in {1..20}; do
sleep 5
if nc -z localhost 8099; then
echo "MCP server is up!"
exit 0
else
echo "Waiting for MCP server to start..."
fi
done
echo "MCP server failed to start within expected time."
exit 1
- name: Run vLLM Server
run: |
set -euo pipefail
source .venv/bin/activate
vllm serve Qwen/Qwen2.5-1.5B-Instruct \
--enable-auto-tool-choice \
--tool-call-parser hermes \
--port 8000 &
VLLM_READY=0
for i in {1..100}; do
if curl -sSf http://localhost:8000/v1/models > /dev/null 2>&1; then
echo "vLLM server is ready!"
VLLM_READY=1
break
fi
echo "Waiting for vLLM server to be ready... (${i})"
sleep 5
done
if [[ "$VLLM_READY" != "1" ]]; then
echo "vLLM server failed to start!"
exit 1
fi
- name: Run RAG Sanity check
run: |
set -ex
source .venv/bin/activate
cd examples/rag
uv run python rag_agent.py
shell: bash
- name: Stop vLLM Server
run: |
set -euo pipefail
pkill -f vllm
for i in {1..60}; do
if ! pgrep -f vllm; then
break
fi
sleep 5
done
- name: RAG training
run: |
set -ex
source .venv/bin/activate
cd examples/rag
../../scripts/restart_ray.sh
sleep 5
PYTHONUNBUFFERED=1 python train_rag.py fast
sleep 10
shell: bash
env:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
id: rag_train
- name: Validate RAG training
run: |
set -ex
# Allow up to 5 rollouts to fail to produce rewards
uv run scripts/validate_example_wandb.py ${{ steps.rag_train.outputs.project_name }} ${{ steps.rag_train.outputs.run_name }} --reward-tolerance 5
env:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
+5 -6
View File
@@ -33,8 +33,7 @@ jobs:
strategy:
matrix:
include:
- python-version: '3.10'
setup-script: 'legacy'
# legacy is omitted because langchain doesn't work with legacy vllm versions
- python-version: '3.12'
setup-script: 'stable'
- python-version: '3.13'
@@ -58,13 +57,13 @@ jobs:
- name: Sync dependencies (latest)
run: |
uv sync --frozen --no-default-groups --extra verl \
--group dev --group experiment --group agents --group torch-gpu-stable
--group dev --group experiment --group agents --group langchain --group torch-gpu-stable
if: matrix.setup-script == 'latest'
- name: Sync dependencies (stable & legacy)
- name: Sync dependencies (stable)
run: |
uv sync --frozen --no-default-groups --extra verl \
--group dev --group experiment --group agents --group torch-gpu-${{ matrix.setup-script }}
if: matrix.setup-script != 'latest'
--group dev --group experiment --group agents --group langchain --group torch-gpu-${{ matrix.setup-script }}
if: matrix.setup-script == 'stable'
- name: Freeze dependencies
run: |
set -ex
+13 -3
View File
@@ -68,13 +68,23 @@ jobs:
path: requirements-freeze.txt
compression-level: 0
- name: Tinker LLM sanity check
# TODO: Currently only test the client tracer implementation.
- name: Tinker LLM sanity check (tracer text)
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/tinker
# TODO: Currently only test the client tracer implementation.
python -m tests.test_tinker_llm
python -m tests.test_tinker_llm tracer-text
shell: bash
env:
TINKER_API_KEY: ${{ secrets.TINKER_API_KEY }}
- name: Tinker LLM sanity check (tracer tool)
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/tinker
python -m tests.test_tinker_llm tracer-tool
shell: bash
env:
TINKER_API_KEY: ${{ secrets.TINKER_API_KEY }}
+18
View File
@@ -0,0 +1,18 @@
# Pre-defined workflow with workflow_dispatch trigger,
# convenient for testing and debugging.
name: Playground
permissions:
contents: read
on:
workflow_dispatch:
jobs:
playground:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run script
run: |
echo "Hello, world!"
+86 -17
View File
@@ -27,13 +27,37 @@ jobs:
github.event_name != 'repository_dispatch' ||
github.event.action == 'ci-gpu' ||
github.event.action == 'ci-all'
name: GPU Test with Python ${{ matrix.python-version }} (${{ matrix.setup-script }})
name: Full Test (${{ matrix.mark.display-name }}, ${{ matrix.env.setup-script }}, Python ${{ matrix.env.python-version }})
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
runs-on: ${{ matrix.mark.runs-on }}
timeout-minutes: 30
strategy:
matrix:
include:
mark:
- id: store
display-name: Store
pytest-mark: 'store' # store tests should not require gpu
runs-on: ubuntu-latest
has-gpu: false
# AgentOps needs to be separated because it injects tricky global state.
- id: agentops
display-name: AgentOps
pytest-mark: 'agentops' # including agentops+litellm tests here
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
has-gpu: true
# Other tests that require GPU
- id: gpu
display-name: GPU required
pytest-mark: '(gpu or llmproxy) and not agentops'
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
has-gpu: true
# Other uncovered tests
- id: others
display-name: Others
pytest-mark: 'not store and not agentops and not gpu and not llmproxy'
runs-on: ubuntu-latest
has-gpu: false
env:
- python-version: '3.10'
setup-script: 'legacy'
- python-version: '3.12'
@@ -43,6 +67,7 @@ jobs:
fail-fast: false
steps:
- name: Check GPU status
if: matrix.mark.has-gpu
run: nvidia-smi
- uses: actions/checkout@v4
with:
@@ -51,16 +76,32 @@ jobs:
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
python-version: ${{ matrix.env.python-version }}
- name: Upgrade dependencies (latest)
run: uv lock --upgrade
if: matrix.setup-script == 'latest'
- name: Sync dependencies (latest)
run: uv sync --frozen --no-default-groups --extra apo --extra mongo --group dev --group agents --group torch-gpu-stable
if: matrix.setup-script == 'latest'
- name: Sync dependencies (stable & legacy)
run: uv sync --frozen --no-default-groups --extra apo --extra mongo --group dev --group agents --group torch-gpu-${{ matrix.setup-script }}
if: matrix.setup-script != 'latest'
if: matrix.env.setup-script == 'latest'
- name: Sync dependencies (latest, gpu)
if: matrix.env.setup-script == 'latest' && matrix.mark.has-gpu
run: uv sync --frozen --no-default-groups --extra apo --extra mongo --group dev --group agents --group langchain --group torch-gpu-stable
# Don't install vllm/pytorch on CPU counterparts
- name: Sync dependencies (latest, cpu)
if: matrix.env.setup-script == 'latest' && !matrix.mark.has-gpu
run: uv sync --frozen --no-default-groups --extra apo --extra mongo --group dev --group agents --group langchain --group core-stable
- name: Sync dependencies (stable, gpu)
if: matrix.env.setup-script == 'stable' && matrix.mark.has-gpu
run: uv sync --frozen --no-default-groups --extra apo --extra mongo --group dev --group agents --group langchain --group torch-gpu-${{ matrix.env.setup-script }}
- name: Sync dependencies (stable, cpu)
if: matrix.env.setup-script == 'stable' && !matrix.mark.has-gpu
run: uv sync --frozen --no-default-groups --extra apo --extra mongo --group dev --group agents --group langchain --group core-stable
# Don't install langchain for legacy dependency because it has conflicts with torch.
- name: Sync dependencies (legacy, gpu)
if: matrix.env.setup-script == 'legacy' && matrix.mark.has-gpu
run: uv sync --frozen --no-default-groups --extra apo --extra mongo --group dev --group agents --group torch-gpu-legacy
- name: Sync dependencies (legacy, cpu)
if: matrix.env.setup-script == 'legacy' && !matrix.mark.has-gpu
run: uv sync --frozen --no-default-groups --extra apo --extra mongo --group dev --group agents --group core-legacy
- name: Freeze dependencies
run: |
set -ex
@@ -70,13 +111,15 @@ jobs:
- name: Upload dependencies artifact
uses: actions/upload-artifact@v4
with:
name: dependencies-tests-full-${{ matrix.python-version }}-${{ matrix.setup-script }}
name: dependencies-tests-full-${{ matrix.mark.id }}-${{ matrix.env.python-version }}-${{ matrix.env.setup-script }}
path: requirements-freeze.txt
compression-level: 0
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: dashboard/package-lock.json
- name: Install JavaScript dependencies
run: cd dashboard && npm ci
- name: Build dashboard
@@ -135,9 +178,10 @@ jobs:
AZURE_API_BASE: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_BASE }}
AZURE_API_KEY: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_KEY }}
# mongo, openai, gpu, all enabled by default
- name: Run tests
run: |
uv run pytest -v --durations=0 tests
uv run pytest -v --durations=0 tests -m "${{ matrix.mark.pytest-mark }}"
env:
PYTEST_ADDOPTS: "--color=yes"
OPENAI_BASE_URL: http://localhost:12306/
@@ -178,11 +222,15 @@ jobs:
run: uv lock --upgrade
if: matrix.setup-script == 'latest'
- name: Sync dependencies (latest)
run: uv sync --frozen --no-default-groups --extra apo --group dev --group agents --group torch-gpu-stable
run: uv sync --frozen --no-default-groups --extra apo --group dev --group agents --group langchain --group torch-gpu-stable
if: matrix.setup-script == 'latest'
- name: Sync dependencies (stable & legacy)
run: uv sync --frozen --no-default-groups --extra apo --group dev --group agents --group torch-gpu-${{ matrix.setup-script }}
if: matrix.setup-script != 'latest'
- name: Sync dependencies (stable)
run: uv sync --frozen --no-default-groups --extra apo --extra mongo --group dev --group agents --group langchain --group torch-gpu-${{ matrix.setup-script }}
if: matrix.setup-script == 'stable'
# Don't install langchain for legacy dependency because it has conflicts with torch.
- name: Sync dependencies (legacy)
run: uv sync --frozen --no-default-groups --extra apo --extra mongo --group dev --group agents --group torch-gpu-legacy
if: matrix.setup-script == 'legacy'
- name: Freeze dependencies
run: |
set -ex
@@ -320,3 +368,24 @@ jobs:
echo "Waiting for llm_proxy.py to finish..."
sleep 5
done
- name: MultiMetrics backend example
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/minimal
python write_metrics.py --duration 8 --prom-port 9105 --prom-host 0.0.0.0 2>&1 | tee metrics.log &
pid=$!
for attempt in $(seq 1 20); do
if curl -sSf http://localhost:9105/metrics | grep -q minimal_requests_total; then
echo "Metrics endpoint responding"
wait $pid
cat metrics.log
exit 0
fi
sleep 1
done
echo "Metrics endpoint did not respond"
exit 1
+46 -14
View File
@@ -19,7 +19,8 @@ jobs:
lint:
strategy:
matrix:
setup: [fast, slow]
setup: [fast, slow, next]
fail-fast: false
name: Lint - ${{ matrix.setup }}
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -32,6 +33,9 @@ jobs:
- name: Sync dependencies (fast)
run: uv sync --frozen --group dev --no-default-groups
if: matrix.setup == 'fast'
- name: Upgrade dependencies (next)
run: uv lock --upgrade
if: matrix.setup == 'next'
- name: Sync dependencies (slow)
run: |
uv sync --frozen \
@@ -44,8 +48,9 @@ jobs:
--group trl \
--group tinker \
--group agents \
--group langchain \
--no-default-groups
if: matrix.setup == 'slow'
if: matrix.setup != 'fast'
# This pre-commit skips JavaScript on purpose.
- name: Run pre-commit
uses: pre-commit/action@v3.0.1
@@ -60,7 +65,7 @@ jobs:
if: matrix.setup == 'fast'
- name: Run pyright (slow)
run: uv run --locked --no-sync pyright -p pyrightconfig.json
if: matrix.setup == 'slow'
if: matrix.setup != 'fast'
lint-js:
name: Lint - JavaScript
@@ -71,6 +76,8 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: dashboard/package-lock.json
- name: Install dependencies
run: cd dashboard && npm ci
- name: Run ESLint
@@ -115,7 +122,28 @@ jobs:
test:
strategy:
matrix:
include:
mark:
# store has many tests and is a good isolated group.
- id: store
display-name: Store
pytest-mark: 'store'
# AgentOps needs to be separated because it injects tricky global state.
- id: agentops
display-name: AgentOps
pytest-mark: 'agentops'
# litellm proxy tests are slow
- id: llmproxy
display-name: LLM proxy
pytest-mark: 'llmproxy'
# Robustness of utilities is important. There are many tests.
- id: utils
display-name: Utilities
pytest-mark: 'utils'
# unmarked tests: adapter, execution engine, etc.
- id: others
display-name: Others
pytest-mark: 'not store and not agentops and not llmproxy and not utils'
env:
- python-version: '3.10'
setup-script: 'legacy'
- python-version: '3.11'
@@ -126,7 +154,7 @@ jobs:
setup-script: 'latest'
fail-fast: false
name: Test with Python ${{ matrix.python-version }} (${{ matrix.setup-script }})
name: Test (${{ matrix.mark.display-name }}, ${{ matrix.env.setup-script }}, Python ${{ matrix.env.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -134,16 +162,16 @@ jobs:
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
python-version: ${{ matrix.env.python-version }}
- name: Upgrade dependencies (latest)
run: uv lock --upgrade
if: matrix.setup-script == 'latest'
if: matrix.env.setup-script == 'latest'
- name: Sync dependencies (latest)
run: uv sync --frozen --no-default-groups --extra apo --group dev --group agents --group core-stable
if: matrix.setup-script == 'latest'
run: uv sync --frozen --no-default-groups --extra apo --group dev --group agents --group langchain --group core-stable
if: matrix.env.setup-script == 'latest'
- name: Sync dependencies (stable & legacy)
run: uv sync --frozen --no-default-groups --extra apo --group dev --group agents --group core-${{ matrix.setup-script }}
if: matrix.setup-script != 'latest'
run: uv sync --frozen --no-default-groups --extra apo --group dev --group agents --group langchain --group core-${{ matrix.env.setup-script }}
if: matrix.env.setup-script != 'latest'
- name: Freeze dependencies
run: |
set -ex
@@ -153,13 +181,15 @@ jobs:
- name: Upload dependencies artifact
uses: actions/upload-artifact@v4
with:
name: dependencies-${{ matrix.python-version }}-${{ matrix.setup-script }}
name: dependencies-${{ matrix.mark.id }}-${{ matrix.env.python-version }}-${{ matrix.env.setup-script }}
path: requirements-freeze.txt
compression-level: 0
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: dashboard/package-lock.json
- name: Install JavaScript dependencies
run: cd dashboard && npm ci
- name: Build dashboard
@@ -167,12 +197,12 @@ jobs:
- name: Run tests
run: |
uv run pytest -v --durations=0 tests -m "not mongo"
uv run pytest -v --durations=0 tests -m "not mongo and not openai and not gpu and (${{ matrix.mark.pytest-mark }})"
env:
PYTEST_ADDOPTS: "--color=yes"
test-js:
name: Test - JavaScript
name: Test (JavaScript)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -182,6 +212,8 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: dashboard/package-lock.json
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
+1
View File
@@ -3,6 +3,7 @@ verl_old
meta-llama/**
debug/*.png
requirements-freeze*.txt
/playground
# Byte-compiled / optimized / DLL files
__pycache__/
+41
View File
@@ -0,0 +1,41 @@
# Repository Guidelines
## Architecture Overview
Agent Lightning runs through a continuous loop: runners and tracers emit spans, `LightningStore` (`agentlightning/store/`) keeps them synchronized, and algorithms in `agentlightning/algorithm/` consume those traces to improve behavior.
## Project Structure & Module Organization
- `agentlightning/`: adapters, execution stack, training loop, tracer, reward logic, and the `agl` CLI.
- `docs/` & `examples/`: narrative and procedural docs (assets in `docs/assets/`, navigation in `mkdocs.yml`) plus runnable workflows whose READMEs point to their companion how-to guides. `docs/how-to` covers task-focused instructions, while `docs/tutorials` explains concepts and subsystems.
- `dashboard/`, `scripts/`, `tests/`: UI bundles, release/dataset/CI automation, and mirrored coverage of the runtime tree. Record download steps rather than committing binaries.
## Build, Test, and Development Commands
- `uv sync --group dev` — provision tooling once per environment.
- `uv run --no-sync pytest -v` — execute the full suite; add a path or `-k expr` to narrow the run.
- `uv run --no-sync pyright` — enforce static typing parity with CI.
- `uv run --no-sync pre-commit run --all-files --show-diff-on-failure` and `uv run --no-sync mkdocs build --strict` — keep formatting tidy and documentation valid.
Always commit the refreshed `uv.lock` when dependencies shift, and mention optional groups (VERL, APO, GPU) in PR notes.
## Common Issues & Fixes
- When `uv run` errors with `Permission denied` under `~/.cache`, override both cache locations inline: ``UV_CACHE="$(pwd)/.cache_uv" XDG_CACHE_HOME="$(pwd)/.cache_xdg" uv run --no-sync <command>``.
## Coding Style & Naming Conventions
- Target `requires-python >= 3.10`, four-space indentation, 120-character lines (though docstrings may run longer), and formatter-owned diffs (Black + isort, `black` profile). Use `snake_case` for modules, functions, and variables; `PascalCase` for classes and React components; lowercase hyphenation for CLI flags, branch names, and TypeScript filenames.
- Maintain exhaustive type hints (pyright enforces them) and prefer shared dataclasses or Pydantic models from `agentlightning.types`.
- Author Google-style docstrings for new modules or public methods—succinct descriptions, no redundant type info, no redundant `Key features/components` bullet points. Use mkdocs styles: `[][]` syntax for cross-references and single backticks for inline code blocks.
- Writing logs is encouraged, especially for long functions with multiple steps and try-except blocks that catch all exceptions. Use `logging.getLogger(__name__)` to get loggers. Distinguish between DEBUG, INFO, WARNING, and ERROR logs.
## Testing Guidelines
- Mirror runtime directories under `tests/` and match filenames for quick traceability.
- Parametrize pytest cases and apply markers (`openai`, `gpu`, `agentops`, `mongo`, `llmproxy`) so optional suites can be skipped via selectors like `-m "not mongo"` yet still exercised in CI.
- Lean on fixtures, favor real stores/spans/agents over mocks, and drive coverage across the majority of branches.
- If an imported module is missing from the environment, check whether `uv sync` has been run with the right groups. Do not make stubs for external dependencies unless necessary.
## Example Contributions
- Ship each example with a README that includes smoke-test instructions so maintainers can validate quickly. The README must contain an "Included Files" section summarizing every file and its role.
- Keep runnable example modules self-contained with a module-level docstring describing CLI usage. Document important or educational classes/functions with targeted docstrings and inline comments where clarity matters.
- Add a CI workflow per example named `examples-<name>.yml` in `.github/workflows/`. Register it in `badge-<name>.yml`, `badge-examples.yml`, and `badge-latest.yml` when applicable so badges stay accurate.
## Commit & Pull Request Guidelines
- Branch from a fresh `main` using `feature/<slug>`, `fix/<slug>`, `docs/<slug>`, or `chore/<slug>`.
- Write imperative, scoped commits, reference issues with `Fixes #123`, and rerun pre-commit plus the relevant pytest/doc builds before pushing.
- Use PR descriptions to summarize intent, list verification commands, call out dependency or docs-navigation updates, and link new docs/examples via `mkdocs.yml` or `examples/README.md`. Include logs for dashboard changes.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+1
View File
@@ -57,6 +57,7 @@ To start using Agent-lightning, check out our [documentation](https://microsoft.
- [DeepWerewolf](https://github.com/af-74413592/DeepWerewolf) — A case study of agent RL training for the Chinese Werewolf game built with AgentScope and Agent Lightning.
- [AgentFlow](https://agentflow.stanford.edu/) — A modular multi-agent framework that combines planner, executor, verifier, and generator agents with the Flow-GRPO algorithm to tackle long-horizon, sparse-reward tasks.
- [Youtu-Agent](https://github.com/TencentCloudADP/Youtu-agent) — Youtu-Agent lets you build and train your agent with ease. Built with [a modified branch](https://github.com/microsoft/agent-lightning/tree/contrib/youtu-agent-lightning) of Agent Lightning, Youtu-Agent has verified up to 128 GPUs RL training on maths/code and search capabilities with steady convergence. Also check [the recipe](https://github.com/TencentCloudADP/youtu-agent/tree/rl/agl).
## ⚡ Architecture
+74 -10
View File
@@ -13,6 +13,7 @@ from pydantic import BaseModel
from agentlightning.emitter.reward import get_reward_value
from agentlightning.types import Span, Triplet
from agentlightning.utils.otel import filter_and_unflatten_attributes
from .base import TraceAdapter
@@ -376,7 +377,8 @@ class TraceTree:
if is_llm_call:
llm_calls.append((self, within_matching_subtree)) # type: ignore
existing_llm_call_response_ids = existing_llm_call_response_ids or set()
if existing_llm_call_response_ids is None:
existing_llm_call_response_ids = set()
if response_id is not None:
existing_llm_call_response_ids.add(response_id)
if within_llm_call is not None:
@@ -490,12 +492,12 @@ class TraceTree:
assign_to: List[Tuple[str, int]] = []
for child in item.children:
if child.id in llm_call_ids:
assign_to.append(child.id) # type: ignore
assign_to.append((child.id, child.end_time)) # type: ignore
agentops_output = item.maybe_reward_dict()
agentops_output = child.maybe_reward_dict()
if agentops_output and agentops_output.get("type") == "reward":
for assign_to_id, assign_to_end_time in reversed(assign_to):
if assign_to_end_time > item.start_time: # type: ignore
if assign_to_end_time > child.start_time: # type: ignore
# This reward happens before the end of the LLM call.
continue
if assign_to_id in rewards:
@@ -505,6 +507,60 @@ class TraceTree:
return rewards
def extract_prompt_image_urls(self, prompt_raw_content: Any) -> List[str]:
"""Extract image URLs from the span attributes, in order of appearance.
Args:
prompt_raw_content: The raw content of the prompt, which can be in one of several formats:
- List[dict]: A list of message entries, each being a dict with at least a "content" key.
- Dict[str, Any]: A dictionary, often with numeric string keys (e.g., `{"0": {...}, "1": {...}}`), where each value is a message entry.
If the dict does not have numeric keys, it is treated as a single message entry.
"""
message_entries: List[Any] = []
if isinstance(prompt_raw_content, list):
message_entries = cast(List[Any], prompt_raw_content)
elif isinstance(prompt_raw_content, dict):
# Common when the attributes expand to {"0": {...}, "prompt_filter_results": ...}
numeric_keys = [
key
for key in cast(Dict[str, Any], prompt_raw_content).keys()
if isinstance(key, str) and key.isdigit() # pyright: ignore[reportUnnecessaryIsInstance]
]
if numeric_keys:
for key in sorted(numeric_keys, key=int):
message_entries.append(prompt_raw_content[key])
else:
message_entries = [prompt_raw_content]
else:
return []
image_urls: List[str] = []
for message in cast(List[Dict[str, Any]], message_entries):
if (
not isinstance(message, dict) # pyright: ignore[reportUnnecessaryIsInstance]
or "content" not in message
):
continue
content = message["content"]
if isinstance(content, str):
try:
content = json.loads(content) # This content should now be a list
except json.JSONDecodeError:
logger.warning(f"Failed to parse message content as JSON: {content}")
continue
if isinstance(content, list):
for content_part in cast(List[Dict[str, Any]], content):
if not isinstance(content_part, dict): # pyright: ignore[reportUnnecessaryIsInstance]
continue
if content_part.get("type") == "image_url":
image_url_dict = cast(Dict[str, Any], content_part.get("image_url"))
if not isinstance(image_url_dict, dict): # pyright: ignore[reportUnnecessaryIsInstance]
continue
if "url" in image_url_dict:
image_urls.append(image_url_dict["url"])
return image_urls
def span_to_triplet(self, span: Span, agent_name: str) -> Triplet:
"""Convert a span to a triplet.
@@ -514,19 +570,27 @@ class TraceTree:
prompt_token_ids = span.attributes.get("prompt_token_ids", []) # type: ignore
response_token_ids = span.attributes.get("response_token_ids", []) # type: ignore
response_id = span.attributes.get("gen_ai.response.id", None) # type: ignore
request_metadata = filter_and_unflatten_attributes(span.attributes, "gen_ai.request")
response_metadata = filter_and_unflatten_attributes(span.attributes, "gen_ai.response")
prompt_raw_content = filter_and_unflatten_attributes(span.attributes, "gen_ai.prompt")
completion_raw_content = filter_and_unflatten_attributes(span.attributes, "gen_ai.completion")
image_urls = self.extract_prompt_image_urls(prompt_raw_content)
prompt_payload = {"token_ids": prompt_token_ids, "raw_content": prompt_raw_content, "image_urls": image_urls}
response_payload = {"token_ids": response_token_ids, "raw_content": completion_raw_content}
logprobs_content = span.attributes.get("logprobs.content", None) # type: ignore
if isinstance(logprobs_content, str):
logprobs_content = json.loads(logprobs_content)
response: Dict[str, Any] = {"token_ids": response_token_ids, "logprobs": logprobs_content}
else:
response = {"token_ids": response_token_ids}
response_payload["logprobs"] = logprobs_content
return Triplet(
prompt={"token_ids": prompt_token_ids},
response=response,
prompt=prompt_payload,
response=response_payload,
reward=None,
metadata=dict(response_id=response_id, agent_name=agent_name),
metadata=dict(
request=request_metadata, response=response_metadata, response_id=response_id, agent_name=agent_name
),
)
def to_trajectory(
+29 -3
View File
@@ -7,23 +7,44 @@ APO with textual gradients that read rollout spans and outputs to modify the pro
- rollout: same pattern as your example, but task is a dict (T_task)
"""
from __future__ import annotations
import asyncio
import logging
import random
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Counter, Dict, Generic, Iterator, List, Optional, Sequence, Set, Tuple, TypedDict, TypeVar, cast
from typing import (
TYPE_CHECKING,
Any,
Counter,
Dict,
Generic,
Iterator,
List,
Optional,
Sequence,
Set,
Tuple,
TypedDict,
TypeVar,
cast,
)
import poml
from openai import AsyncOpenAI
from agentlightning.adapter.messages import TraceToMessages
from agentlightning.algorithm.base import Algorithm
from agentlightning.algorithm.utils import batch_iter_over_dataset
from agentlightning.algorithm.utils import batch_iter_over_dataset, with_llm_proxy, with_store
from agentlightning.reward import find_final_reward
from agentlightning.types import Dataset, NamedResources, PromptTemplate, Rollout, RolloutMode, RolloutStatus
if TYPE_CHECKING:
from agentlightning.llm_proxy import LLMProxy
from agentlightning.store.base import LightningStore
logger = logging.getLogger(__name__)
T_task = TypeVar("T_task")
@@ -360,8 +381,10 @@ class APO(Algorithm, Generic[T_task]):
)
return new_prompt
@with_store
async def get_rollout_results(
self,
store: LightningStore,
rollout: List[Rollout],
*,
prefix: Optional[str] = None,
@@ -379,7 +402,6 @@ class APO(Algorithm, Generic[T_task]):
List of rollout results formatted for APO processing.
"""
rollout_results: List[RolloutResultForAPO] = []
store = self.get_store()
adapter = self.get_adapter()
for r in rollout:
spans = await store.query_spans(r.rollout_id)
@@ -776,8 +798,12 @@ class APO(Algorithm, Generic[T_task]):
prefix=prefix,
)
@with_llm_proxy()
@with_store
async def run(
self,
store: LightningStore, # Injected by decorator - callers should not provide this parameter
llm_proxy: Optional[LLMProxy], # Injected by decorator - callers should not provide this parameter
train_dataset: Optional[Dataset[T_task]] = None,
val_dataset: Optional[Dataset[T_task]] = None,
) -> None:
+12 -3
View File
@@ -5,11 +5,16 @@ from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import Any, List, Literal, Optional
from typing import TYPE_CHECKING, Any, List, Literal, Optional
from agentlightning.types import Attempt, Dataset, Rollout, RolloutStatus, Span
from .base import Algorithm
from .utils import with_llm_proxy, with_store
if TYPE_CHECKING:
from agentlightning.llm_proxy import LLMProxy
from agentlightning.store.base import LightningStore
logger = logging.getLogger(__name__)
@@ -36,6 +41,8 @@ class Baseline(FastAlgorithm):
finish, and logs every collected span and reward. It is primarily useful as
a smoke test for the platform plumbing rather than a performant trainer.
The baseline algorithm will auto-start a LLM proxy if one is provided and not yet started.
Args:
n_epochs: Number of dataset passes to execute for both the train and val
splits during developer experiments.
@@ -180,8 +187,12 @@ class Baseline(FastAlgorithm):
await asyncio.sleep(self.polling_interval)
@with_llm_proxy()
@with_store
async def run(
self,
store: LightningStore, # Injected by decorator - callers should not provide this parameter
llm_proxy: Optional[LLMProxy], # Injected by decorator - callers should not provide this parameter
train_dataset: Optional[Dataset[Any]] = None,
val_dataset: Optional[Dataset[Any]] = None,
) -> None:
@@ -202,8 +213,6 @@ class Baseline(FastAlgorithm):
logger.debug(f"Train indices: {train_indices}")
logger.debug(f"Val indices: {val_indices}")
store = self.get_store()
# Currently we only supports a single resource update at the start.
initial_resources = self.get_initial_resources()
if initial_resources is not None:
+135 -1
View File
@@ -1,11 +1,42 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import functools
import logging
import random
from typing import Iterator, List, Sequence, TypeVar
from collections.abc import Coroutine
from typing import (
TYPE_CHECKING,
Any,
Callable,
Concatenate,
Iterator,
List,
Literal,
Optional,
ParamSpec,
Sequence,
TypeVar,
overload,
)
from agentlightning.types import Dataset
if TYPE_CHECKING:
from agentlightning.llm_proxy import LLMProxy
from agentlightning.store.base import LightningStore
from .base import Algorithm
T_task = TypeVar("T_task")
T_algo = TypeVar("T_algo", bound="Algorithm")
P = ParamSpec("P")
R = TypeVar("R")
logger = logging.getLogger(__name__)
def batch_iter_over_dataset(dataset: Dataset[T_task], batch_size: int) -> Iterator[Sequence[T_task]]:
@@ -41,3 +72,106 @@ def batch_iter_over_dataset(dataset: Dataset[T_task], batch_size: int) -> Iterat
if len(current_batch) == batch_size:
yield [dataset[index] for index in current_batch]
current_batch = []
def with_store(
func: Callable[Concatenate[T_algo, LightningStore, P], Coroutine[Any, Any, R]],
) -> Callable[Concatenate[T_algo, P], Coroutine[Any, Any, R]]:
"""Inject the algorithm's `LightningStore` into coroutine methods.
The decorator calls `Algorithm.get_store()` once per invocation and passes the
resulting store as an explicit argument to the wrapped coroutine. Decorated
methods therefore receive the resolved store even when invoked by helper
utilities rather than directly by the algorithm.
Args:
func: The coroutine that expects `(self, store, *args, **kwargs)`.
Returns:
A coroutine wrapper that automatically retrieves the store and forwards it
to `func`.
"""
@functools.wraps(func)
async def wrapper(self: T_algo, *args: P.args, **kwargs: P.kwargs) -> R:
store = self.get_store()
return await func(self, store, *args, **kwargs)
return wrapper
@overload
def with_llm_proxy(
required: Literal[False] = False,
auto_start: bool = True,
) -> Callable[
[Callable[Concatenate[T_algo, Optional[LLMProxy], P], Coroutine[Any, Any, R]]],
Callable[Concatenate[T_algo, P], Coroutine[Any, Any, R]],
]: ...
@overload
def with_llm_proxy(
required: Literal[True],
auto_start: bool = True,
) -> Callable[
[Callable[Concatenate[T_algo, LLMProxy, P], Coroutine[Any, Any, R]]],
Callable[Concatenate[T_algo, P], Coroutine[Any, Any, R]],
]: ...
def with_llm_proxy(
required: bool = False,
auto_start: bool = True,
) -> Callable[
[Callable[..., Coroutine[Any, Any, Any]]],
Callable[..., Coroutine[Any, Any, Any]],
]:
"""Resolve and optionally lifecycle-manage the configured LLM proxy.
Args:
required: When True, raises `ValueError` if the algorithm does not have an
[`LLMProxy`][agentlightning.LLMProxy] set. When False, the wrapped coroutine receives
`None` if no proxy is available.
auto_start: When True, [`LLMProxy.start()`][agentlightning.LLMProxy.start] is invoked if the proxy is not
already running before calling `func` and [`LLMProxy.stop()`][agentlightning.LLMProxy.stop] is
called afterwards.
Returns:
A decorator that injects the [`LLMProxy`][agentlightning.LLMProxy] (or `None`) as the first
argument after `self` and manages automatic startup/shutdown when requested.
"""
def decorator(
func: Callable[..., Coroutine[Any, Any, Any]],
) -> Callable[..., Coroutine[Any, Any, Any]]:
@functools.wraps(func)
async def wrapper(self: Algorithm, *args: Any, **kwargs: Any) -> Any:
llm_proxy = self.get_llm_proxy()
if required and llm_proxy is None:
raise ValueError(
"LLM proxy is required but not configured. Call set_llm_proxy() before using this method."
)
auto_started = False
if auto_start and llm_proxy is not None:
if llm_proxy.is_running():
logger.info("Proxy is already running, skipping start")
else:
logger.info("Starting proxy, managed by the algorithm")
await llm_proxy.start()
auto_started = True
try:
# At type level, overloads guarantee that if `required=True`
# then `func` expects a non-optional LLMProxy.
return await func(self, llm_proxy, *args, **kwargs)
finally:
if auto_started and llm_proxy is not None:
logger.info("Stopping proxy, managed by the algorithm")
await llm_proxy.stop()
return wrapper
return decorator
+26 -2
View File
@@ -1,6 +1,8 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any, Optional
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Optional, Type
from hydra import compose, initialize
from omegaconf import OmegaConf
@@ -10,6 +12,10 @@ from agentlightning.client import AgentLightningClient
from agentlightning.types import Dataset
from agentlightning.verl.entrypoint import run_ppo # type: ignore
if TYPE_CHECKING:
from agentlightning.verl.daemon import AgentModeDaemon
from agentlightning.verl.trainer import AgentLightningTrainer
class VERL(Algorithm):
"""VERL-powered algorithm that delegates training to the VERL PPO runner.
@@ -23,6 +29,8 @@ class VERL(Algorithm):
config: Dictionary mirroring the overrides passed to the VERL CLI. The
overrides are merged with VERL's packaged defaults via Hydra before
launching training.
trainer_cls: Optional override for the trainer class. Experimental.
daemon_cls: Optional override for the daemon class. Experimental.
Examples:
```python
@@ -90,7 +98,12 @@ class VERL(Algorithm):
```
"""
def __init__(self, config: dict[str, Any]):
def __init__(
self,
config: dict[str, Any],
trainer_cls: Optional[Type[AgentLightningTrainer]] = None,
daemon_cls: Optional[Type[AgentModeDaemon]] = None,
):
super().__init__()
# Compose the base config exactly like your decorator:
@@ -102,6 +115,8 @@ class VERL(Algorithm):
# Allow adding new fields
OmegaConf.set_struct(base_cfg, False)
self.config = OmegaConf.merge(base_cfg, override_conf)
self.trainer_cls = trainer_cls
self.daemon_cls = daemon_cls
def run(
self,
@@ -119,6 +134,11 @@ class VERL(Algorithm):
adapter have been garbage-collected when using the V1 execution
mode.
"""
from agentlightning.verl.daemon import AgentModeDaemon
from agentlightning.verl.trainer import AgentLightningTrainer
trainer_cls = self.trainer_cls or AgentLightningTrainer
daemon_cls = self.daemon_cls or AgentModeDaemon
try:
store = self.get_store()
except Exception:
@@ -130,6 +150,8 @@ class VERL(Algorithm):
store=None,
llm_proxy=None,
adapter=None,
trainer_cls=trainer_cls,
daemon_cls=daemon_cls,
)
else:
print("Store is set. Assuming v1 execution mode.")
@@ -142,6 +164,8 @@ class VERL(Algorithm):
store=store,
llm_proxy=llm_proxy,
adapter=adapter,
trainer_cls=trainer_cls,
daemon_cls=daemon_cls,
)
def get_client(self) -> AgentLightningClient:
+1
View File
@@ -12,6 +12,7 @@ from typing import Dict, Iterable, Tuple
_SUBCOMMANDS: Dict[str, Tuple[str, str]] = {
"vllm": ("agentlightning.cli.vllm", "Run the vLLM CLI with Agent Lightning instrumentation."),
"store": ("agentlightning.cli.store", "Run a LightningStore server."),
"prometheus": ("agentlightning.cli.prometheus", "Serve Prometheus metrics from the multiprocess registry."),
"agentops": ("agentlightning.cli.agentops_server", "Start the AgentOps server manager."),
}
+115
View File
@@ -0,0 +1,115 @@
# Copyright (c) Microsoft. All rights reserved.
"""Serve Prometheus metrics from the Agent Lightning multiprocess registry."""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
from pathlib import Path
from typing import Iterable
from fastapi import FastAPI
from prometheus_client import make_asgi_app # pyright: ignore[reportUnknownVariableType]
from agentlightning.logging import setup as setup_logging
from agentlightning.utils.metrics import get_prometheus_registry
from agentlightning.utils.server_launcher import PythonServerLauncher, PythonServerLauncherArgs
logger = logging.getLogger(__name__)
def ensure_prometheus_dir() -> str:
"""Ensure PROMETHEUS_MULTIPROC_DIR is set and the directory exists."""
directory = os.getenv("PROMETHEUS_MULTIPROC_DIR")
if directory is None:
raise ValueError("PROMETHEUS_MULTIPROC_DIR is not set.")
Path(directory).mkdir(parents=True, exist_ok=True)
logger.info("Serving Prometheus multiprocess metrics from %s", directory)
return directory
def create_prometheus_app(metrics_path: str = "/v1/prometheus") -> FastAPI:
"""Create a FastAPI app that exposes Prometheus metrics and a health endpoint.
Args:
metrics_path: URL path to expose the Prometheus metrics endpoint on.
Returns:
A FastAPI application ready to serve metrics.
"""
if not metrics_path.startswith("/"):
raise ValueError("metrics_path must start with '/'.")
normalized_path = metrics_path.rstrip("/")
if normalized_path in ("", "/"):
raise ValueError("metrics_path must not be '/'. Choose a sub-path such as /v1/prometheus.")
app = FastAPI(title="Agent Lightning Prometheus exporter", docs_url=None, redoc_url=None)
metrics_app = make_asgi_app(registry=get_prometheus_registry()) # pyright: ignore[reportUnknownVariableType]
app.mount(normalized_path, metrics_app) # pyright: ignore[reportUnknownArgumentType]
@app.get("/health")
async def healthcheck() -> dict[str, str]: # pyright: ignore[reportUnusedFunction]
return {"status": "ok"}
return app
def main(argv: Iterable[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Serve Prometheus metrics outside the LightningStore server.")
parser.add_argument("--host", default="0.0.0.0", help="Host to bind the metrics server to.")
parser.add_argument("--port", type=int, default=4748, help="Port to expose the Prometheus metrics on.")
parser.add_argument(
"--metrics-path",
default="/v1/prometheus",
help="HTTP path used to expose metrics. Must start with '/' and not be the root path.",
)
parser.add_argument(
"--log-level",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Configure the logging level for the metrics server.",
)
parser.add_argument(
"--access-log",
action="store_true",
help="Enable uvicorn access logs. Disabled by default to reduce noise.",
)
args = parser.parse_args(list(argv) if argv is not None else None)
setup_logging(args.log_level)
ensure_prometheus_dir()
try:
app = create_prometheus_app(args.metrics_path)
except ValueError as exc:
logger.error("Failed to configure prometheus app: %s", exc)
return 1
launcher_args = PythonServerLauncherArgs(
host=args.host,
port=args.port,
log_level=getattr(logging, args.log_level),
access_log=args.access_log,
healthcheck_url="/health",
)
launcher = PythonServerLauncher(app, launcher_args)
try:
asyncio.run(launcher.run_forever())
except KeyboardInterrupt:
logger.info("Received shutdown signal. Stopping Prometheus server.")
except RuntimeError as exc:
logger.error("Prometheus server failed to start: %s", exc, exc_info=True)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+39 -7
View File
@@ -7,11 +7,18 @@ from __future__ import annotations
import argparse
import asyncio
import logging
from typing import Iterable
from typing import Iterable, List
from agentlightning import setup_logging
from agentlightning.store.client_server import LightningStoreServer
from agentlightning.store.memory import InMemoryLightningStore
from agentlightning.utils.metrics import (
ConsoleMetricsBackend,
MetricsBackend,
MultiMetricsBackend,
PrometheusMetricsBackend,
setup_multiprocess_prometheus,
)
logger = logging.getLogger(__name__)
@@ -33,9 +40,10 @@ def main(argv: Iterable[str] | None = None) -> int:
help="Configure the logging level for the store.",
)
parser.add_argument(
"--prometheus",
action="store_true",
help="Enable Prometheus metrics.",
"--tracker",
nargs="+",
choices=["prometheus", "console"],
help="Enable metrics tracking. Repeat for multiple trackers.",
)
parser.add_argument(
"--n-workers",
@@ -63,12 +71,36 @@ def main(argv: Iterable[str] | None = None) -> int:
setup_logging(args.log_level)
trackers: List[MetricsBackend] = []
if args.tracker:
if "prometheus" in args.tracker:
logger.info("Enabling Prometheus metrics tracking.")
if args.n_workers > 1:
# This has to be done before prometheus_client is imported
setup_multiprocess_prometheus()
logger.info("Setting up Prometheus multiprocess directory for metrics tracking.")
trackers.append(PrometheusMetricsBackend())
if "console" in args.tracker:
logger.info("Enabling console metrics tracking.")
trackers.append(ConsoleMetricsBackend())
if len(trackers) == 0:
tracker: MetricsBackend | None = None
elif len(trackers) == 1:
tracker = trackers[0]
else:
tracker = MultiMetricsBackend(trackers)
if args.backend == "memory":
store = InMemoryLightningStore(prometheus=args.prometheus)
store = InMemoryLightningStore(
thread_safe=True, # Using thread_safe store for server
tracker=tracker,
)
elif args.backend == "mongo":
from agentlightning.store.mongo import MongoLightningStore
store = MongoLightningStore(client=args.mongo_uri, prometheus=args.prometheus)
store = MongoLightningStore(mongo_uri=args.mongo_uri, tracker=tracker)
else:
raise ValueError(f"Invalid backend: {args.backend}")
@@ -84,7 +116,7 @@ def main(argv: Iterable[str] | None = None) -> int:
port=args.port,
cors_allow_origins=args.cors_origins,
launch_mode=launch_mode,
prometheus=args.prometheus,
tracker=tracker,
n_workers=args.n_workers,
)
try:
+2 -1
View File
@@ -1,6 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
from .annotation import emit_annotation
from .annotation import emit_annotation, operation
from .exception import emit_exception
from .message import emit_message, get_message_value
from .object import emit_object, get_object_value
@@ -16,6 +16,7 @@ from .reward import (
__all__ = [
"reward",
"operation",
"emit_reward",
"get_reward_value",
"get_rewards_from_span",
+319 -3
View File
@@ -1,15 +1,36 @@
# Copyright (c) Microsoft. All rights reserved.
"""Helpers for emitting annotation spans."""
"""Helpers for emitting annotation/operation spans."""
import asyncio
import functools
import inspect
import json
import logging
from typing import Any, Dict
from types import TracebackType
from typing import (
Any,
Callable,
ContextManager,
Dict,
Optional,
Tuple,
Type,
TypeVar,
Union,
cast,
overload,
)
from opentelemetry import trace
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.trace import Status, StatusCode
from agentlightning.semconv import AGL_ANNOTATION
from agentlightning.semconv import AGL_ANNOTATION, AGL_OPERATION, LightningSpanAttributes
from agentlightning.utils.otel import flatten_attributes, get_tracer
_FnType = TypeVar("_FnType", bound=Callable[..., Any])
logger = logging.getLogger(__name__)
@@ -46,3 +67,298 @@ def emit_annotation(annotation: Dict[str, Any], propagate: bool = True) -> Reada
raise ValueError(f"Span is not a ReadableSpan: {span}")
return span
def _safe_json_dump(obj: Any) -> str:
"""Serialize an object to JSON, falling back to ``str(obj)`` if needed.
Args:
obj: Object to be serialized.
Returns:
The JSON-encoded string representation of the object, or its string
representation if JSON encoding fails.
"""
try:
return json.dumps(obj, default=str, ensure_ascii=False)
except Exception:
return str(obj)
class OperationContext:
"""Context manager and decorator for tracing operations.
This class manages an OpenTelemetry span for a logical unit of work. It can
be used either:
* As a decorator, in which case inputs and outputs are inferred
automatically from the wrapped function's signature.
* As a context manager, in which case inputs and outputs can be recorded
explicitly via :meth:`set_input` and :meth:`set_output`.
Attributes:
name: Human-readable span name.
initial_attributes: Attributes applied when the span is created.
tracer: OpenTelemetry tracer used to create spans.
span: The currently active span, if any.
"""
def __init__(self, name: str, attributes: Dict[str, Any], *, propagate: bool = True) -> None:
"""Initialize a new operation context.
Args:
name: Human-readable name of the span.
attributes: Initial attributes attached to the span. Values are
JSON-serialized where necessary.
propagate: Whether the span should be sent to active exporters.
"""
self.name: str = name
self.initial_attributes: Dict[str, Any] = attributes
self.propagate: bool = propagate
self.tracer: trace.Tracer = get_tracer(use_active_span_processor=propagate)
self.span: Optional[trace.Span] = None
self._ctx_token: Optional[ContextManager[Any]] = None
def __enter__(self) -> "OperationContext":
"""Enter the context manager and start a new span.
Returns:
The current :class:`OperationContext` instance with an active span.
"""
# 1. Start the span with initial attributes (JSON serialized)
sanitized_attrs = {
k: _safe_json_dump(v) if not isinstance(v, (str, int, float, bool)) else v
for k, v in self.initial_attributes.items()
}
self.span = self.tracer.start_span(self.name, attributes=sanitized_attrs)
self._ctx_token = trace.use_span(self.span, end_on_exit=True)
self._ctx_token.__enter__()
return self
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
"""Exit the context manager and finish the span.
Any exception raised inside the context is recorded on the span and the
span status is set to error.
Args:
exc_type: Exception type, if an exception occurred.
exc_val: Exception instance, if an exception occurred.
exc_tb: Traceback object, if an exception occurred.
"""
# 1. Record Exception if present
if exc_val and self.span:
self.span.record_exception(exc_val)
self.span.set_status(Status(StatusCode.ERROR, str(exc_val)))
# 2. Close span
if self._ctx_token:
self._ctx_token.__exit__(exc_type, exc_val, exc_tb)
def set_input(self, *args: Any, **kwargs: Any) -> None:
"""Record input arguments on the current span.
Positional arguments are stored under the ``input.args`` attribute,
and keyword arguments are stored under ``input.<name>`` attributes.
This is intended for use inside a ``with operation(...) as op`` block.
Args:
*args: Positional arguments to record.
**kwargs: Keyword arguments to record.
"""
if not self.span:
return
if args:
self.span.set_attribute("input.args", _safe_json_dump(args))
if kwargs:
for k, v in kwargs.items():
self.span.set_attribute(f"input.{k}", _safe_json_dump(v))
def set_output(self, output: Any) -> None:
"""Record the output value on the current span.
This is intended for use inside a ``with operation(...) as op`` block.
Args:
output: The output value to record.
"""
if not self.span:
return
self.span.set_attribute("output", _safe_json_dump(output))
def __call__(self, fn: _FnType) -> _FnType:
"""Wrap a callable so its execution is traced in a span.
When used as a decorator, a new span is created for each call to
the wrapped function. The bound arguments are recorded as input
attributes, the return value is recorded as an output attribute,
and any exception is recorded and marks the span as an error.
Args:
fn: The function or coroutine function to wrap.
Returns:
The wrapped callable.
"""
function_name = fn.__name__
sig = inspect.signature(fn)
def _record_auto_inputs(span: trace.Span, args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> None:
"""Bind arguments to signature and log them on the span.
Args:
span: Span on which to record attributes.
args: Positional arguments passed to the wrapped callable.
kwargs: Keyword arguments passed to the wrapped callable.
"""
try:
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for k, v in bound.arguments.items():
span.set_attribute(
f"{LightningSpanAttributes.OPERATION_INPUT.value}.{k}",
_safe_json_dump(v),
)
except Exception:
span.set_attribute(
f"{LightningSpanAttributes.OPERATION_INPUT.value}.args",
_safe_json_dump(args),
)
span.set_attribute(
f"{LightningSpanAttributes.OPERATION_INPUT.value}.kwargs",
_safe_json_dump(kwargs),
)
if asyncio.iscoroutinefunction(fn) or inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
"""Async wrapper that traces the wrapped coroutine."""
# Reuse __enter__ logic via 'with self' would share state incorrectly
# across concurrent calls. We must create a new span per call.
# So we manually reimplement the span logic for the wrapper here.
sanitized_attrs = {
k: _safe_json_dump(v) if not isinstance(v, (str, int, float, bool)) else v
for k, v in self.initial_attributes.items()
}
with self.tracer.start_as_current_span(self.name, attributes=sanitized_attrs) as span:
span.set_attribute(LightningSpanAttributes.OPERATION_NAME.value, function_name)
_record_auto_inputs(span, args, kwargs)
try:
result = await fn(*args, **kwargs)
span.set_attribute(
LightningSpanAttributes.OPERATION_OUTPUT.value,
_safe_json_dump(result),
)
return result
except Exception as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
raise
return cast(_FnType, async_wrapper)
else:
@functools.wraps(fn)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
"""Sync wrapper that traces the wrapped callable."""
sanitized_attrs = {
k: _safe_json_dump(v) if not isinstance(v, (str, int, float, bool)) else v
for k, v in self.initial_attributes.items()
}
with self.tracer.start_as_current_span(self.name, attributes=sanitized_attrs) as span:
span.set_attribute(LightningSpanAttributes.OPERATION_NAME.value, function_name)
_record_auto_inputs(span, args, kwargs)
try:
result = fn(*args, **kwargs)
span.set_attribute(
LightningSpanAttributes.OPERATION_OUTPUT.value,
_safe_json_dump(result),
)
return result
except Exception as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
raise
return cast(_FnType, sync_wrapper)
@overload
def operation(fn: _FnType, *, propagate: bool = True, **additional_attributes: Any) -> _FnType: ...
@overload
def operation(*, propagate: bool = True, **additional_attributes: Any) -> OperationContext: ...
def operation(
fn: Optional[_FnType] = None,
*,
propagate: bool = True,
**additional_attributes: Any,
) -> Union[_FnType, OperationContext]:
"""Entry point for tracking operations.
This helper can be used either as a decorator or as a context manager.
The span name is fixed to [`AGL_OPERATION`][agentlightning.semconv.AGL_OPERATION];
custom span names are not supported. Any keyword arguments are recorded as span attributes.
Usage as a decorator:
```python
@operation
def func(...):
...
@operation(category="compute")
def func(...):
...
```
Usage as a context manager:
```python
with operation(user_id=123) as op:
op.set_input(data=data)
# ... do work ...
op.set_output(result)
```
Args:
fn: When used as `@operation`, this is the wrapped function.
When used as `operation(**attrs)`, this should be omitted (or
left as `None`) and only keyword attributes are provided.
propagate: Whether spans should use the active span processor. When False,
spans will stay local and not be exported.
**additional_attributes: Additional span attributes to attach at
creation time.
Returns:
Either a wrapped callable (when used as a decorator) or an
[`OperationContext`][agentlightning.emitter.annotation.OperationContext]
(when used as a context manager factory).
"""
# Case 1: Used as @operation (bare decorator or with attributes)
if callable(fn):
# Create context with fixed name, then immediately wrap the function
return OperationContext(AGL_OPERATION, additional_attributes, propagate=propagate)(fn)
# Case 2: Used as operation(...) / with operation(...)
# Custom span names are intentionally not supported; use AGL_OPERATION.
if fn is not None:
raise ValueError("Custom span names are intentionally not supported when used as a context manager.")
return OperationContext(AGL_OPERATION, additional_attributes, propagate=propagate)
+22 -2
View File
@@ -143,6 +143,10 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
logger.debug("Algorithm bundle starting against endpoint %s", wrapper_store.endpoint)
await algorithm(wrapper_store, stop_evt)
logger.debug("Algorithm bundle completed successfully")
except asyncio.CancelledError:
logger.info("Algorithm received CancelledError; signaling stop event")
stop_evt.set()
raise
except KeyboardInterrupt:
logger.warning("Algorithm received KeyboardInterrupt; signaling stop event")
stop_evt.set()
@@ -179,6 +183,10 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
logger.debug("Runner %s executing with provided store", worker_id)
await runner(client_store, worker_id, stop_evt)
logger.debug("Runner %s completed successfully", worker_id)
except asyncio.CancelledError:
logger.debug("Runner %s received CancelledError; signaling stop event", worker_id)
stop_evt.set()
raise
except KeyboardInterrupt:
logger.warning("Runner %s received KeyboardInterrupt; signaling stop event", worker_id)
stop_evt.set()
@@ -210,7 +218,13 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
def _runner_sync(runner: RunnerBundle, worker_id: int, store: LightningStore, stop_evt: ExecutionEvent) -> None:
# Runners are executed in child processes; each process owns its own
# event loop to keep the asyncio scheduler isolated.
asyncio.run(self._execute_runner(runner, worker_id, store, stop_evt))
try:
asyncio.run(self._execute_runner(runner, worker_id, store, stop_evt))
except KeyboardInterrupt:
logger.warning("Runner (asyncio) %s received KeyboardInterrupt; exiting gracefully", worker_id)
except BaseException as exc:
logger.exception("Runner (asyncio) %s crashed by %s; signaling stop event", worker_id, exc)
raise
for i in range(self.n_runners):
process = cast(
@@ -234,7 +248,13 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
"""Used when `main_process == "runner"`."""
def _algorithm_sync(algorithm: AlgorithmBundle, store: LightningStore, stop_evt: ExecutionEvent) -> None:
asyncio.run(self._execute_algorithm(algorithm, store, stop_evt))
try:
asyncio.run(self._execute_algorithm(algorithm, store, stop_evt))
except KeyboardInterrupt:
logger.warning("Algorithm (asyncio.run) received KeyboardInterrupt; exiting gracefully")
except BaseException as exc:
logger.exception("Algorithm (asyncio.run) crashed by %s; signaling stop event", exc)
raise
process = cast(
multiprocessing.Process,
@@ -6,6 +6,7 @@ AGENTOPS_INSTALLED: bool = False
AGENTOPS_LANGCHAIN_INSTALLED: bool = False
LITELLM_INSTALLED: bool = False
VLLM_INSTALLED: bool = False
WEAVE_INSTALLED: bool = False
try:
from . import agentops # type: ignore
@@ -38,6 +39,13 @@ try:
except ImportError:
pass
try:
from . import weave # type: ignore
WEAVE_INSTALLED = True # type: ignore
except ImportError:
pass
def instrument_all():
"""Instrument all the instrumentation libraries."""
@@ -111,3 +119,20 @@ def uninstrument_all():
warnings.warn("agentops_langchain is installed but uninstrument_agentops_langchain could not be imported.")
else:
warnings.warn("Agentops-langchain integration is not installed. It's therefore not uninstrumented.")
def instrument_weave():
if WEAVE_INSTALLED:
from .weave import instrument_weave
instrument_weave()
def uninstrument_weave():
if WEAVE_INSTALLED:
try:
from .weave import uninstrument_weave
uninstrument_weave()
except ImportError:
warnings.warn("weave is installed but uninstrument_weave could not be imported.")
+139
View File
@@ -0,0 +1,139 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import os
from typing import Any, Callable, Optional
import requests
logger = logging.getLogger(__name__)
__all__ = [
"instrument_weave",
"uninstrument_weave",
]
# Module-level storage for originals
_original_default_entity_name_getter: Callable[..., Any] | None = None
_original_upsert_project_getter: Callable[..., Any] | None = None
_original_weave_get = False
_original_weave_post = False
def instrument_weave():
"""
Patch the Weave/W&B integration to bypass actual network calls for testing.
- Mocks HTTP POST/GET requests
- Patches wandb.Api methods
- Silences Weave logging
- Sets dummy WANDB_API_KEY if not provided
"""
try:
import weave
from weave.compat import wandb # type: ignore
except ImportError:
logger.warning("Weave or wandb not installed; cannot uninstrument.")
return
_weave_tracer_entity_name = "weave_tracer_entity"
def default_entity_name_getter(_self) -> str: # type: ignore
return _weave_tracer_entity_name
def upsert_project_getter(
_self, project: str, description: Optional[str] = None, entity: Optional[str] = None # type: ignore
) -> dict[str, Any]:
return {
"upsertModel": {
"model": {
"name": project,
"description": description or "",
"entity": entity or _weave_tracer_entity_name,
}
},
"project": "weave_tracer_project",
}
# Mock network requests to avoid real HTTP calls
def post(url: str, *args: Any, **kwargs: Any) -> requests.Response:
response = requests.Response()
response.status_code = 200
response._content = b'{"digest": "mocked_digest"}'
return response
def get(url: str, *args: Any, **kwargs: Any) -> requests.Response:
response = requests.Response()
response.status_code = 200
response._content = b'{"min_required_weave_python_version": "0.52.14"}'
return response
# Patch API methods and HTTP requests
global _original_default_entity_name_getter
global _original_upsert_project_getter
global _original_weave_post
global _original_weave_get
_original_default_entity_name_getter = wandb.Api.default_entity_name # type: ignore
_original_upsert_project_getter = wandb.Api.upsert_project # type: ignore
_original_weave_post = weave.utils.http_requests.session.post # type: ignore
_original_weave_get = weave.utils.http_requests.session.get # type: ignore
# Patch API methods and HTTP requests
wandb.Api.default_entity_name = default_entity_name_getter # type: ignore
wandb.Api.upsert_project = upsert_project_getter # type: ignore
weave.utils.http_requests.session.post = post # type: ignore
weave.utils.http_requests.session.get = get # type: ignore
# Silence Weave logging
for name in logging.root.manager.loggerDict:
if name.startswith("weave"):
logging.getLogger(name).disabled = True
# Set dummy API key if missing
if not os.environ.get("WANDB_API_KEY"):
os.environ["WANDB_API_KEY"] = "dumped_api_key_for_weave_tracer"
# if needed in future tests, enable this and replace WF_TRACE_SERVER_URL to local server
# full_url = f"http://127.0.0.1:{_port}"
# os.environ["WF_TRACE_SERVER_URL"] = full_url
def uninstrument_weave():
"""
Restore the original Weave/W&B integration methods and HTTP requests.
"""
try:
import weave
from weave.compat import wandb # type: ignore
except ImportError:
logger.warning("Weave or wandb not installed; cannot uninstrument.")
return
global _original_default_entity_name_getter
if _original_default_entity_name_getter is not None:
wandb.Api.default_entity_name = _original_default_entity_name_getter # type: ignore
_original_default_entity_name_getter = None
logger.info("restored wandb.Api.default_entity_name")
global _original_upsert_project_getter
if _original_upsert_project_getter is not None:
wandb.Api.upsert_project = _original_upsert_project_getter # type: ignore
_original_upsert_project_getter = None
logger.info("restored wandb.Api.upsert_project")
global _original_weave_post
if _original_weave_post is not None:
weave.utils.http_requests.session.post = _original_weave_post # type: ignore
_original_weave_post = None
logger.info("restored weave.utils.http_requests.session.post")
global _original_weave_get
if _original_weave_get is not None:
weave.utils.http_requests.session.get = _original_weave_get # type: ignore
_original_weave_get = None
logger.info("restored weave.utils.http_requests.session.get")
# Restore Weave logging
for name in logging.root.manager.loggerDict:
if name.startswith("weave"):
logging.getLogger(name).disabled = False
+26 -23
View File
@@ -33,8 +33,8 @@ from opentelemetry.sdk.trace import ReadableSpan
from agentlightning.litagent import LitAgent
from agentlightning.reward import emit_reward, find_final_reward
from agentlightning.store.base import LightningStore
from agentlightning.tracer.agentops import AgentOpsTracer
from agentlightning.tracer.base import Tracer
from agentlightning.tracer.otel import OtelTracer
from agentlightning.types import (
AttemptedRollout,
Hook,
@@ -73,7 +73,7 @@ class LitAgentRunner(Runner[T_task]):
max_rollouts: Optional[int] = None,
poll_interval: float = 5.0,
heartbeat_interval: float = 10.0,
interval_jitter: float = 0.1,
interval_jitter: float = 0.5,
heartbeat_launch_mode: Literal["asyncio", "thread"] = "asyncio",
) -> None:
"""Initialize the agent runner.
@@ -277,13 +277,21 @@ class LitAgentRunner(Runner[T_task]):
store = self.get_store()
trace_spans: list[ReadableSpan] | list[Span] = []
result_recognized: bool = False
# Case 0: result is None
if raw_result is None:
trace_spans = self._tracer.get_last_trace()
result_recognized = True
# Case 1: result is a float (final reward)
if isinstance(raw_result, float):
if isinstance(raw_result, (bool, int, float)):
if isinstance(raw_result, (bool, int)):
logger.warning(
f"{self._log_prefix(rollout.rollout_id)} Reward is not a number, got: {type(raw_result)}. "
"Auto converting to float."
)
raw_result = float(raw_result)
# Preserve the existing spans before another span is emitted
trace_spans = list(self._tracer.get_last_trace())
# This will NOT emit another span to the tracer
@@ -291,7 +299,9 @@ class LitAgentRunner(Runner[T_task]):
# We add it to the store manually
await store.add_otel_span(rollout.rollout_id, rollout.attempt.attempt_id, reward_span)
trace_spans.append(reward_span)
result_recognized = True
# Case 2-3: result is a list
if isinstance(raw_result, list):
# For rollout methods that return a list, we assume that the returned spans
# are the complete span set from the whole rollout
@@ -299,10 +309,7 @@ class LitAgentRunner(Runner[T_task]):
# Case 2: result is a list of ReadableSpan (OpenTelemetry spans)
if len(raw_result) > 0 and all(isinstance(t, ReadableSpan) for t in raw_result):
if not isinstance(
self._tracer, AgentOpsTracer
): # TODO: this should be replaced with general OpenTelemetry tracer in next version
if not isinstance(self._tracer, OtelTracer):
for span in raw_result:
await store.add_otel_span(
rollout.rollout_id, rollout.attempt.attempt_id, cast(ReadableSpan, span)
@@ -313,6 +320,7 @@ class LitAgentRunner(Runner[T_task]):
"The traces should have already been added to the store. "
"No need to return anything from rollout."
)
result_recognized = True
# Case 3: result is a list of Span (agentlightning spans)
elif len(raw_result) > 0 and all(isinstance(t, Span) for t in raw_result):
@@ -320,6 +328,7 @@ class LitAgentRunner(Runner[T_task]):
for span in raw_result:
await store.add_span(cast(Span, span))
trace_spans = raw_result
result_recognized = True
# Left over cases for list
elif len(raw_result) == 0:
@@ -328,6 +337,7 @@ class LitAgentRunner(Runner[T_task]):
"Please check your rollout implementation."
)
trace_spans = raw_result
result_recognized = True
else:
types = [type(t).__name__ for t in raw_result][:10]
@@ -336,6 +346,12 @@ class LitAgentRunner(Runner[T_task]):
f"but got: {', '.join(types)}..."
)
if not result_recognized:
raise TypeError(
f"Invalid raw result type. It's expected to be none, float, or a list of ReadableSpan or Span, "
f"but got: {type(raw_result).__name__}..."
)
return trace_spans
async def _emit_heartbeat(self, store: LightningStore) -> None:
@@ -566,6 +582,7 @@ class LitAgentRunner(Runner[T_task]):
while not (event is not None and event.is_set()):
logger.debug(f"{self._log_prefix()} Try to poll for next rollout.")
next_rollout = await store.dequeue_rollout(worker_id=self.get_worker_id())
logger.debug(f"{self._log_prefix()} Next rollout retrieved: {next_rollout}")
if next_rollout is None:
logger.debug(
f"{self._log_prefix()} No rollout to poll. Waiting for {self._poll_interval} seconds."
@@ -577,16 +594,6 @@ class LitAgentRunner(Runner[T_task]):
if next_rollout is None:
return
try:
# Claim the rollout but updating the current worker id
await store.update_attempt(
next_rollout.rollout_id, next_rollout.attempt.attempt_id, worker_id=self.get_worker_id()
)
except Exception:
# This exception could happen if the rollout is dequeued and the other end died for some reason
logger.exception(f"{self._log_prefix()} Exception during update_attempt, giving up the rollout.")
continue
# Execute the step
await self._step_impl(next_rollout)
@@ -640,12 +647,8 @@ class LitAgentRunner(Runner[T_task]):
else:
resources_id = None
attempted_rollout = await self.get_store().start_rollout(input=input, mode=mode, resources_id=resources_id)
# Register the attempt as running by the current worker
await self.get_store().update_attempt(
attempted_rollout.rollout_id,
attempted_rollout.attempt.attempt_id,
worker_id=self.get_worker_id(),
attempted_rollout = await self.get_store().start_rollout(
input=input, mode=mode, resources_id=resources_id, worker_id=self.get_worker_id()
)
rollout_id = await self._step_impl(attempted_rollout, raise_on_exception=True)
+14
View File
@@ -29,6 +29,11 @@ AGL_EXCEPTION = "agentlightning.exception"
Used by the exception emitter to record exception details.
"""
AGL_OPERATION = "agentlightning.operation"
"""Agent-lightning's standard span name for functions.
Wrap function or code-blocks as operations.
"""
AGL_VIRTUAL = "agentlightning.virtual"
"""Agent-lightning's standard span name for virtual operations.
@@ -84,6 +89,15 @@ class LightningSpanAttributes(Enum):
OBJECT_JSON = "agentlightning.object.json"
"""Attribute name for object serialized value (JSON) in object spans."""
OPERATION_NAME = "agentlightning.operation.name"
"""Attribute name for operation name in operation spans, normally the function name."""
OPERATION_INPUT = "agentlightning.operation.input"
"""Attribute name for operation input in operation spans."""
OPERATION_OUTPUT = "agentlightning.operation.output"
"""Attribute name for operation output in operation spans."""
class RewardAttributes(Enum):
"""Multi-dimensional reward attributes will look like:
+51 -3
View File
@@ -10,10 +10,12 @@ from agentlightning.types import (
Attempt,
AttemptedRollout,
AttemptStatus,
EnqueueRolloutRequest,
NamedResources,
ResourcesUpdate,
Rollout,
RolloutConfig,
RolloutMode,
RolloutStatus,
Span,
TaskInput,
@@ -156,10 +158,11 @@ class LightningStore:
async def start_rollout(
self,
input: TaskInput,
mode: Literal["train", "val", "test"] | None = None,
mode: RolloutMode | None = None,
resources_id: str | None = None,
config: RolloutConfig | None = None,
metadata: Dict[str, Any] | None = None,
worker_id: str | None = None,
) -> AttemptedRollout:
"""Register a rollout and immediately create its first attempt.
@@ -182,6 +185,7 @@ class LightningStore:
resources_id: Concrete resource snapshot to execute against; defaults to the latest stored snapshot.
config: Rollout retry/timeout policy. Should default to a fresh [`RolloutConfig`][agentlightning.RolloutConfig].
metadata: Free-form metadata persisted verbatim with the rollout.
worker_id: Optional worker identifier to associate the new attempt with.
Returns:
The fully-populated [`AttemptedRollout`][agentlightning.AttemptedRollout] including
@@ -227,6 +231,22 @@ class LightningStore:
"""
raise NotImplementedError()
async def enqueue_many_rollouts(self, rollouts: Sequence[EnqueueRolloutRequest]) -> Sequence[Rollout]:
"""Persist multiple rollouts in `queuing` state.
The implementation can delegate to [`enqueue_rollout()`][agentlightning.LightningStore.enqueue_rollout]
per request and preserves the input ordering. Subclasses can override to provide
more efficient bulk enqueue semantics.
Args:
rollouts: Rollout submission payloads mirroring [`enqueue_rollout()`][agentlightning.LightningStore.enqueue_rollout]'s
parameters. Each entry requires `input` and can optionally include other fields.
Returns:
Rollouts enqueued in the same order as `rollouts`.
"""
raise NotImplementedError()
async def dequeue_rollout(self, worker_id: Optional[str] = None) -> Optional[AttemptedRollout]:
"""Claim the oldest queued rollout and transition it to `preparing`.
@@ -243,6 +263,9 @@ class LightningStore:
* Optionally refresh the caller's [`Worker`][agentlightning.Worker] telemetry
(e.g., `last_dequeue_time`) when `worker_id` is provided.
Args:
worker_id: Optional worker identifier to associate the claimed attempt with.
Returns:
The next attempt to execute, or `None` when no eligible rollouts are queued.
@@ -251,7 +274,30 @@ class LightningStore:
"""
raise NotImplementedError()
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
async def dequeue_many_rollouts(
self,
*,
limit: int = 1,
worker_id: Optional[str] = None,
) -> Sequence[AttemptedRollout]:
"""Claim up to `limit` queued rollouts without blocking.
The implementation can repeatedly invokes
[`dequeue_rollout()`][agentlightning.LightningStore.dequeue_rollout] until reaching
the requested limit or the queue is empty. Subclasses can override it to fetch
multiple rollouts atomically.
Args:
limit: Maximum number of rollouts to claim. Non-positive values return an empty list.
worker_id: Optional worker identifier passed through to each dequeue call.
Returns:
Attempted rollouts claimed in FIFO order. May contain fewer than `limit` entries
when the queue is exhausted.
"""
raise NotImplementedError()
async def start_attempt(self, rollout_id: str, worker_id: Optional[str] = None) -> AttemptedRollout:
"""Create a manual retry attempt for an existing rollout.
This is typically invoked by runners that wish to retry outside of the
@@ -262,6 +308,7 @@ class LightningStore:
Args:
rollout_id: Unique identifier of the rollout receiving a new attempt.
worker_id: Optional worker identifier to associate the new attempt with.
Returns:
The rollout paired with its newly-created attempt.
@@ -719,7 +766,8 @@ class LightningStore:
When `attempt_id` is `"latest"` the update must target the attempt with the highest
`sequence_id`; otherwise it must target the specific attempt. Implementations should
propagate status changes to the rollout (for example via [`propagate_status()`][agentlightning.store.utils.propagate_status])
propagate status changes to the rollout (for example
via [`rollout_status_from_attempt()`][agentlightning.store.utils.rollout_status_from_attempt])
once the latest attempt transitions to a terminal state.
Similar to [`update_rollout()`][agentlightning.LightningStore.update_rollout],
+314 -150
View File
@@ -47,6 +47,7 @@ from agentlightning.types import (
Attempt,
AttemptedRollout,
AttemptStatus,
EnqueueRolloutRequest,
NamedResources,
PaginatedResult,
ResourcesUpdate,
@@ -58,10 +59,12 @@ from agentlightning.types import (
Worker,
WorkerStatus,
)
from agentlightning.utils.metrics import MetricsBackend, get_prometheus_registry
from agentlightning.utils.otlp import handle_otlp_export, spans_from_proto
from agentlightning.utils.server_launcher import LaunchMode, PythonServerLauncher, PythonServerLauncherArgs
from .base import UNSET, LightningStore, LightningStoreCapabilities, LightningStoreStatistics, Unset
from .collection.base import resolve_error_type
from .utils import LATENCY_BUCKETS
server_logger = logging.getLogger("agentlightning.store.server")
@@ -81,12 +84,26 @@ class RolloutRequest(BaseModel):
resources_id: Optional[str] = None
config: Optional[RolloutConfig] = None
metadata: Optional[Dict[str, Any]] = None
worker_id: Optional[str] = None
class DequeueRolloutRequest(BaseModel):
worker_id: Optional[str] = None
class StartAttemptRequest(BaseModel):
worker_id: Optional[str] = None
class EnqueueManyRolloutsRequest(BaseModel):
rollouts: List[EnqueueRolloutRequest]
class DequeueManyRolloutsRequest(BaseModel):
limit: int = 1
worker_id: Optional[str] = None
class QueryRolloutsRequest(BaseModel):
status_in: Optional[List[RolloutStatus]] = Field(FastAPIQuery(default=None))
rollout_id_in: Optional[List[str]] = Field(FastAPIQuery(default=None))
@@ -223,7 +240,7 @@ class LightningStoreServer(LightningStore):
launcher_args: The arguments to use for the server launcher.
It's not allowed to set `host`, `port`, `launch_mode` together with `launcher_args`.
n_workers: The number of workers to run in the server. Only applicable for `mp` launch mode.
prometheus: Whether to enable Prometheus metrics.
tracker: The metrics tracker to use for the server.
"""
def __init__(
@@ -235,7 +252,7 @@ class LightningStoreServer(LightningStore):
launch_mode: LaunchMode = "thread",
launcher_args: PythonServerLauncherArgs | None = None,
n_workers: int = 1,
prometheus: bool = False,
tracker: MetricsBackend | None = None,
):
super().__init__()
self.store = store
@@ -253,6 +270,7 @@ class LightningStoreServer(LightningStore):
port=port,
launch_mode=launch_mode,
healthcheck_url=API_V1_AGL_PREFIX + "/health",
n_workers=n_workers,
)
store_capabilities = self.store.capabilities
@@ -272,7 +290,7 @@ class LightningStoreServer(LightningStore):
app=self.app,
args=self.launcher_args,
)
self._prometheus = prometheus
self._tracker = tracker
self._lock: threading.Lock = threading.Lock()
self._cors_allow_origins = self._normalize_cors_origins(cors_allow_origins)
@@ -317,7 +335,6 @@ class LightningStoreServer(LightningStore):
return {
"launcher_args": self.launcher_args,
"server_launcher": self.server_launcher,
"_prometheus": self._prometheus,
"_owner_pid": self._owner_pid,
}
@@ -335,11 +352,12 @@ class LightningStoreServer(LightningStore):
self.store = None
self.launcher_args = state["launcher_args"]
self.server_launcher = state["server_launcher"]
self._prometheus = state["_prometheus"]
self._tracker = None
self._owner_pid = state["_owner_pid"]
self._cors_allow_origins = state.get("_cors_allow_origins")
self._client = None
self._lock = threading.Lock()
self._prometheus_registry = None
# Do NOT reconstruct app, _uvicorn_config, _uvicorn_server
# to avoid transferring server state to subprocess
@@ -420,9 +438,10 @@ class LightningStoreServer(LightningStore):
api = APIRouter(prefix=API_V1_PREFIX)
# The outermost-layer of monitoring
if self._prometheus:
self._setup_prometheus(api=api, app=self.app)
if self._tracker is not None:
self._setup_metrics(api=api, app=self.app)
# TODO: This should only be enabled in development mode.
@self.app.middleware("http")
async def _app_exception_handler( # pyright: ignore[reportUnusedFunction]
request: Request, call_next: Callable[[Request], Awaitable[Response]]
@@ -458,7 +477,9 @@ class LightningStoreServer(LightningStore):
request: Request, call_next: Callable[[Request], Awaitable[Response]]
):
# If not API request, just pass through
if not request.url.path.startswith(API_V1_AGL_PREFIX):
if not request.url.path.startswith(API_V1_AGL_PREFIX) and not request.url.path.startswith(
API_V1_PREFIX + "/traces"
):
return await call_next(request)
start = time.perf_counter()
@@ -522,22 +543,38 @@ class LightningStoreServer(LightningStore):
async def health(): # pyright: ignore[reportUnusedFunction]
return {"status": "ok"}
@api.post(API_AGL_PREFIX + "/queues/rollouts/enqueue", status_code=201, response_model=Rollout)
async def enqueue_rollout(request: RolloutRequest): # pyright: ignore[reportUnusedFunction]
return await self.enqueue_rollout(
input=request.input,
mode=request.mode,
resources_id=request.resources_id,
config=request.config,
metadata=request.metadata,
)
@api.post(API_AGL_PREFIX + "/queues/rollouts/enqueue", status_code=201, response_model=List[Rollout])
async def enqueue_rollouts( # pyright: ignore[reportUnusedFunction]
request: EnqueueManyRolloutsRequest,
) -> List[Rollout]:
enqueue_requests = request.rollouts
if not enqueue_requests:
return []
if len(enqueue_requests) == 1:
single = enqueue_requests[0]
rollout = await self.enqueue_rollout(
input=single.input,
mode=single.mode,
resources_id=single.resources_id,
config=single.config,
metadata=single.metadata,
)
return [rollout]
rollouts = await self.enqueue_many_rollouts(enqueue_requests)
return list(rollouts)
@api.post(API_AGL_PREFIX + "/queues/rollouts/dequeue", response_model=Optional[AttemptedRollout])
async def dequeue_rollout( # pyright: ignore[reportUnusedFunction]
request: DequeueRolloutRequest | None = Body(None),
):
worker_id = request.worker_id if request else None
return await self.dequeue_rollout(worker_id=worker_id)
@api.post(API_AGL_PREFIX + "/queues/rollouts/dequeue", response_model=List[AttemptedRollout])
async def dequeue_rollouts( # pyright: ignore[reportUnusedFunction]
request: DequeueManyRolloutsRequest | None = Body(None),
) -> List[AttemptedRollout]:
payload = request or DequeueManyRolloutsRequest()
if payload.limit <= 0:
return []
if payload.limit == 1:
single = await self.dequeue_rollout(worker_id=payload.worker_id)
return [single] if single else []
rollouts = await self.dequeue_many_rollouts(limit=payload.limit, worker_id=payload.worker_id)
return list(rollouts)
@api.post(API_AGL_PREFIX + "/rollouts", status_code=201, response_model=AttemptedRollout)
async def start_rollout(request: RolloutRequest): # pyright: ignore[reportUnusedFunction]
@@ -547,6 +584,7 @@ class LightningStoreServer(LightningStore):
resources_id=request.resources_id,
config=request.config,
metadata=request.metadata,
worker_id=request.worker_id,
)
@api.get(API_AGL_PREFIX + "/rollouts", response_model=PaginatedResult[Union[AttemptedRollout, Rollout]])
@@ -565,6 +603,24 @@ class LightningStoreServer(LightningStore):
)
return _build_paginated_response(results, limit=params.limit, offset=params.offset)
@api.post(API_AGL_PREFIX + "/rollouts/search", response_model=PaginatedResult[Union[AttemptedRollout, Rollout]])
async def search_rollouts(request: QueryRolloutsRequest): # pyright: ignore[reportUnusedFunction]
_validate_paginated_request(request, Rollout)
status_in = request.status_in if "status_in" in request.model_fields_set else None
rollout_id_in = request.rollout_id_in if "rollout_id_in" in request.model_fields_set else None
# Get all rollouts from the underlying store
results = await self.query_rollouts(
status_in=status_in,
rollout_id_in=rollout_id_in,
rollout_id_contains=request.rollout_id_contains,
filter_logic=request.filter_logic,
sort_by=request.sort_by,
sort_order=request.sort_order,
limit=request.limit,
offset=request.offset,
)
return _build_paginated_response(results, limit=request.limit, offset=request.offset)
@api.get(API_AGL_PREFIX + "/rollouts/{rollout_id}", response_model=Union[AttemptedRollout, Rollout])
async def get_rollout_by_id(rollout_id: str): # pyright: ignore[reportUnusedFunction]
return await self.get_rollout_by_id(rollout_id)
@@ -597,8 +653,25 @@ class LightningStoreServer(LightningStore):
)
@api.post(API_AGL_PREFIX + "/rollouts/{rollout_id}/attempts", status_code=201, response_model=AttemptedRollout)
async def start_attempt(rollout_id: str): # pyright: ignore[reportUnusedFunction]
return await self.start_attempt(rollout_id)
async def start_attempt( # pyright: ignore[reportUnusedFunction]
rollout_id: str, request: StartAttemptRequest | None = Body(None)
):
worker_id = request.worker_id if request else None
return await self.start_attempt(rollout_id, worker_id=worker_id)
@api.post(API_AGL_PREFIX + "/rollouts/{rollout_id}/attempts/search", response_model=PaginatedResult[Attempt])
async def search_attempts( # pyright: ignore[reportUnusedFunction]
rollout_id: str, request: QueryAttemptsRequest
):
_validate_paginated_request(request, Attempt)
attempts = await self.query_attempts(
rollout_id,
sort_by=request.sort_by,
sort_order=request.sort_order,
limit=request.limit,
offset=request.offset,
)
return _build_paginated_response(attempts, limit=request.limit, offset=request.offset)
@api.post(API_AGL_PREFIX + "/rollouts/{rollout_id}/attempts/{attempt_id}", response_model=Attempt)
async def update_attempt( # pyright: ignore[reportUnusedFunction]
@@ -627,6 +700,21 @@ class LightningStoreServer(LightningStore):
)
return _build_paginated_response(workers, limit=params.limit, offset=params.offset)
@api.post(API_AGL_PREFIX + "/workers/search", response_model=PaginatedResult[Worker])
async def search_workers(request: QueryWorkersRequest): # pyright: ignore[reportUnusedFunction]
_validate_paginated_request(request, Worker)
status_in = request.status_in if "status_in" in request.model_fields_set else None
workers = await self.query_workers(
status_in=status_in,
worker_id_contains=request.worker_id_contains,
filter_logic=request.filter_logic,
sort_by=request.sort_by,
sort_order=request.sort_order,
limit=request.limit,
offset=request.offset,
)
return _build_paginated_response(workers, limit=request.limit, offset=request.offset)
@api.get(API_AGL_PREFIX + "/workers/{worker_id}", response_model=Optional[Worker])
async def get_worker(worker_id: str): # pyright: ignore[reportUnusedFunction]
return await self.get_worker_by_id(worker_id)
@@ -719,6 +807,28 @@ class LightningStoreServer(LightningStore):
)
return _build_paginated_response(spans, limit=params.limit, offset=params.offset)
@api.post(API_AGL_PREFIX + "/spans/search", response_model=PaginatedResult[Span])
async def search_spans(request: QuerySpansRequest): # pyright: ignore[reportUnusedFunction]
_validate_paginated_request(request, Span)
spans = await self.query_spans(
request.rollout_id,
request.attempt_id,
trace_id=request.trace_id,
trace_id_contains=request.trace_id_contains,
span_id=request.span_id,
span_id_contains=request.span_id_contains,
parent_id=request.parent_id,
parent_id_contains=request.parent_id_contains,
name=request.name,
name_contains=request.name_contains,
filter_logic=request.filter_logic,
sort_by=request.sort_by,
sort_order=request.sort_order,
limit=request.limit,
offset=request.offset,
)
return _build_paginated_response(spans, limit=request.limit, offset=request.offset)
@api.post(API_AGL_PREFIX + "/spans/next", response_model=NextSequenceIdResponse)
async def get_next_span_sequence_id(request: NextSequenceIdRequest): # pyright: ignore[reportUnusedFunction]
sequence_id = await self.get_next_span_sequence_id(request.rollout_id, request.attempt_id)
@@ -737,50 +847,34 @@ class LightningStoreServer(LightningStore):
# Finally, mount the dashboard assets
self._setup_dashboard()
def _setup_prometheus(self, api: APIRouter, app: FastAPI):
def _setup_metrics(self, api: APIRouter, app: FastAPI):
"""Setup Prometheus metrics endpoints."""
try:
from prometheus_client import make_asgi_app # type: ignore
from prometheus_client import (
REGISTRY,
CollectorRegistry,
Counter,
Histogram,
multiprocess,
)
except ImportError:
raise ImportError(
"Prometheus client is not installed. Please either install it or set prometheus to False."
)
if self._tracker is None:
return
# Multi-process mode: https://prometheus.github.io/client_python/multiprocess/
is_multiprocess = self.launcher_args.launch_mode == "mp" and self.launcher_args.n_workers > 1
if is_multiprocess:
registry = CollectorRegistry()
multiprocess.MultiProcessCollector(registry)
else:
registry = REGISTRY
HTTP_REQUESTS = Counter(
"http_requests_total",
"Total HTTP requests",
["method", "path", "status_code"],
self._tracker.register_counter(
"agl.http.total",
["path", "method", "status"],
group_level=2,
)
HTTP_LATENCY = Histogram(
"http_request_duration_seconds",
"Latency of HTTP requests",
["method", "path"],
self._tracker.register_histogram(
"agl.http.latency",
["path", "method", "status"],
buckets=LATENCY_BUCKETS,
group_level=2,
)
def get_template_path(path: str) -> str:
# Handle "latest" keywords BEFORE generic IDs
if path.endswith("/attempts/latest") and "/rollouts/" in path:
return re.sub(r"rollouts/[^/]+/attempts/latest$", "rollouts/{rollout_id}/attempts/latest", path)
elif path.endswith("/resources/latest"):
if path.endswith("/attempts/search") and "/rollouts/" in path:
return re.sub(r"rollouts/[^/]+/attempts/search$", "rollouts/{rollout_id}/attempts/search", path)
if path.endswith("/resources/latest"):
return path
elif "enqueue" in path or "dequeue" in path:
if path.endswith("/search"):
return path
if "enqueue" in path or "dequeue" in path:
return path
# Handle generic IDs
@@ -793,27 +887,53 @@ class LightningStoreServer(LightningStore):
return path
@app.middleware("http")
async def prometheus_http_middleware( # pyright: ignore[reportUnusedFunction]
async def tracking_middleware( # pyright: ignore[reportUnusedFunction]
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
if self._tracker is None:
return await call_next(request)
start = time.perf_counter()
response = await call_next(request)
elapsed = time.perf_counter() - start
status = 520 # Default to 520 if things crash hard
# Strip the ID-specific URL parts
path = get_template_path(request.url.path)
method = request.method
status = response.status_code
try:
response = await call_next(request)
status = response.status_code
return response
except asyncio.CancelledError:
# Client disconnected (Timeout)
status = 499 # Standard Nginx code for "Client Closed Request"
raise # Re-raise to let Uvicorn handle the cleanup
except Exception as exc:
status = resolve_error_type(exc)
raise
finally:
# This block executes NO MATTER WHAT happens above
elapsed = time.perf_counter() - start
HTTP_REQUESTS.labels(method, path, status).inc()
HTTP_LATENCY.labels(method, path).observe(elapsed)
# Strip the ID-specific URL parts
path = get_template_path(request.url.path)
method = request.method
return response
await self._tracker.inc_counter(
"agl.http.total",
labels={"method": method, "path": path, "status": str(status)},
)
await self._tracker.observe_histogram(
"agl.http.latency",
value=elapsed,
labels={"method": method, "path": path, "status": str(status)},
)
metrics_app = make_asgi_app(registry=registry) # type: ignore
if self._tracker.has_prometheus():
from prometheus_client import make_asgi_app # pyright: ignore[reportUnknownVariableType]
# This App would need to be accessed via /v1/prometheus/ (note the trailing slash)
app.mount(api.prefix + "/prometheus", metrics_app) # pyright: ignore[reportUnknownArgumentType]
metrics_app = make_asgi_app( # pyright: ignore[reportUnknownVariableType]
registry=get_prometheus_registry()
)
# This App would need to be accessed via /v1/prometheus/ (note the trailing slash)
app.mount(api.prefix + "/prometheus", metrics_app) # pyright: ignore[reportUnknownArgumentType]
def _setup_otlp(self, api: APIRouter):
"""Setup OTLP endpoints."""
@@ -934,6 +1054,7 @@ class LightningStoreServer(LightningStore):
resources_id: str | None = None,
config: RolloutConfig | None = None,
metadata: Dict[str, Any] | None = None,
worker_id: Optional[str] = None,
) -> AttemptedRollout:
return await self._call_store_method(
"start_rollout",
@@ -942,6 +1063,7 @@ class LightningStoreServer(LightningStore):
resources_id,
config,
metadata,
worker_id,
)
async def enqueue_rollout(
@@ -961,11 +1083,22 @@ class LightningStoreServer(LightningStore):
metadata,
)
async def enqueue_many_rollouts(self, rollouts: Sequence[EnqueueRolloutRequest]) -> Sequence[Rollout]:
return await self._call_store_method("enqueue_many_rollouts", rollouts)
async def dequeue_rollout(self, worker_id: Optional[str] = None) -> Optional[AttemptedRollout]:
return await self._call_store_method("dequeue_rollout", worker_id)
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
return await self._call_store_method("start_attempt", rollout_id)
async def dequeue_many_rollouts(
self,
*,
limit: int = 1,
worker_id: Optional[str] = None,
) -> Sequence[AttemptedRollout]:
return await self._call_store_method("dequeue_many_rollouts", limit=limit, worker_id=worker_id)
async def start_attempt(self, rollout_id: str, worker_id: Optional[str] = None) -> AttemptedRollout:
return await self._call_store_method("start_attempt", rollout_id, worker_id)
async def query_rollouts(
self,
@@ -1439,6 +1572,7 @@ class LightningStoreClient(LightningStore):
resources_id: str | None = None,
config: RolloutConfig | None = None,
metadata: Dict[str, Any] | None = None,
worker_id: Optional[str] = None,
) -> AttemptedRollout:
data = await self._request_json(
"post",
@@ -1449,6 +1583,7 @@ class LightningStoreClient(LightningStore):
resources_id=resources_id,
config=config,
metadata=metadata,
worker_id=worker_id,
).model_dump(exclude_none=False),
)
return AttemptedRollout.model_validate(data)
@@ -1461,18 +1596,64 @@ class LightningStoreClient(LightningStore):
config: RolloutConfig | None = None,
metadata: Dict[str, Any] | None = None,
) -> Rollout:
request_body = EnqueueManyRolloutsRequest(
rollouts=[
EnqueueRolloutRequest(
input=input,
mode=mode,
resources_id=resources_id,
config=config,
metadata=metadata,
)
]
).model_dump(exclude_none=False)
data = await self._request_json(
"post",
"/queues/rollouts/enqueue",
json=RolloutRequest(
input=input,
mode=mode,
resources_id=resources_id,
config=config,
metadata=metadata,
).model_dump(exclude_none=False),
json=request_body,
)
return Rollout.model_validate(data)
if not data:
raise RuntimeError("enqueue_rollout returned no rollouts")
return Rollout.model_validate(data[0])
async def enqueue_many_rollouts(self, rollouts: Sequence[EnqueueRolloutRequest]) -> Sequence[Rollout]:
if not rollouts:
return []
request_body = EnqueueManyRolloutsRequest(rollouts=list(rollouts)).model_dump(exclude_none=False)
data = await self._request_json(
"post",
"/queues/rollouts/enqueue",
json=request_body,
)
return [Rollout.model_validate(entry) for entry in data]
async def _dequeue_batch(
self,
*,
limit: int,
worker_id: Optional[str],
) -> List[AttemptedRollout]:
if limit <= 0:
return []
session = await self._get_session()
url = f"{self.server_address}/queues/rollouts/dequeue"
payload: Dict[str, Any] = {"limit": limit}
if worker_id is not None:
payload["worker_id"] = worker_id
try:
async with session.post(url, json=payload) as resp:
resp.raise_for_status()
data = await resp.json()
self._dequeue_was_successful = True
return [AttemptedRollout.model_validate(item) for item in data]
except Exception as e:
if self._dequeue_was_successful:
if self._dequeue_first_unsuccessful:
client_logger.warning(f"dequeue_rollout failed with exception: {e}")
self._dequeue_first_unsuccessful = False
client_logger.debug("dequeue_rollout failed with exception. Details:", exc_info=True)
# Else ignore the exception because the server is not ready yet
return []
async def dequeue_rollout(self, worker_id: Optional[str] = None) -> Optional[AttemptedRollout]:
"""
@@ -1485,30 +1666,23 @@ class LightningStoreClient(LightningStore):
This method does NOT retry on failures. If any exception occurs (network error,
server error, etc.), it logs the error and returns None immediately.
"""
session = await self._get_session()
url = f"{self.server_address}/queues/rollouts/dequeue"
request_kwargs: Dict[str, Any] = {}
if worker_id is not None:
request_kwargs["json"] = {"worker_id": worker_id}
try:
async with session.post(url, **request_kwargs) as resp:
resp.raise_for_status()
data = await resp.json()
self._dequeue_was_successful = True
return AttemptedRollout.model_validate(data) if data else None
except Exception as e:
if self._dequeue_was_successful:
if self._dequeue_first_unsuccessful:
client_logger.warning(f"dequeue_rollout failed with exception: {e}")
self._dequeue_first_unsuccessful = False
client_logger.debug("dequeue_rollout failed with exception. Details:", exc_info=True)
# Else ignore the exception because the server is not ready yet
return None
attempts = await self._dequeue_batch(limit=1, worker_id=worker_id)
return attempts[0] if attempts else None
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
async def dequeue_many_rollouts(
self,
*,
limit: int = 1,
worker_id: Optional[str] = None,
) -> Sequence[AttemptedRollout]:
return await self._dequeue_batch(limit=limit, worker_id=worker_id)
async def start_attempt(self, rollout_id: str, worker_id: Optional[str] = None) -> AttemptedRollout:
payload = {"worker_id": worker_id} if worker_id is not None else None
data = await self._request_json(
"post",
f"/rollouts/{rollout_id}/attempts",
json=payload,
)
return AttemptedRollout.model_validate(data)
@@ -1526,29 +1700,25 @@ class LightningStoreClient(LightningStore):
status: Optional[Sequence[RolloutStatus]] = None,
rollout_ids: Optional[Sequence[str]] = None,
) -> PaginatedResult[Union[AttemptedRollout, Rollout]]:
params_list: List[Tuple[str, Any]] = []
def _extend(key: str, values: Sequence[Any]) -> None:
for value in values:
params_list.append((key, value))
resolved_status = status_in if status_in is not None else status
resolved_rollout_ids = rollout_id_in if rollout_id_in is not None else rollout_ids
payload: Dict[str, Any] = {
"limit": limit,
"offset": offset,
}
if resolved_status is not None:
_extend("status_in", resolved_status)
payload["status_in"] = resolved_status
if resolved_rollout_ids is not None:
_extend("rollout_id_in", resolved_rollout_ids)
payload["rollout_id_in"] = resolved_rollout_ids
if rollout_id_contains is not None:
params_list.append(("rollout_id_contains", rollout_id_contains))
params_list.append(("filter_logic", filter_logic))
payload["rollout_id_contains"] = rollout_id_contains
payload["filter_logic"] = filter_logic
if sort_by is not None:
params_list.append(("sort_by", sort_by))
params_list.append(("sort_order", sort_order))
params_list.append(("limit", limit))
params_list.append(("offset", offset))
payload["sort_by"] = sort_by
payload["sort_order"] = sort_order
data = await self._request_json("get", "/rollouts", params=params_list or None)
data = await self._request_json("post", "/rollouts/search", json=payload)
items = [
(
AttemptedRollout.model_validate(item)
@@ -1568,14 +1738,14 @@ class LightningStoreClient(LightningStore):
limit: int = -1,
offset: int = 0,
) -> PaginatedResult[Attempt]:
params: List[Tuple[str, Any]] = [
("limit", limit),
("offset", offset),
]
payload: Dict[str, Any] = {
"limit": limit,
"offset": offset,
}
if sort_by is not None:
params.append(("sort_by", sort_by))
params.append(("sort_order", sort_order))
data = await self._request_json("get", f"/rollouts/{rollout_id}/attempts", params=params)
payload["sort_by"] = sort_by
payload["sort_order"] = sort_order
data = await self._request_json("post", f"/rollouts/{rollout_id}/attempts/search", json=payload)
items = [Attempt.model_validate(item) for item in data["items"]]
return PaginatedResult(items=items, limit=data["limit"], offset=data["offset"], total=data["total"])
@@ -1795,32 +1965,30 @@ class LightningStoreClient(LightningStore):
sort_by: Optional[str] = "sequence_id",
sort_order: Literal["asc", "desc"] = "asc",
) -> PaginatedResult[Span]:
params: List[Tuple[str, Any]] = [("rollout_id", rollout_id)]
payload: Dict[str, Any] = {"rollout_id": rollout_id, "limit": limit, "offset": offset}
if attempt_id is not None:
params.append(("attempt_id", attempt_id))
payload["attempt_id"] = attempt_id
if trace_id is not None:
params.append(("trace_id", trace_id))
payload["trace_id"] = trace_id
if trace_id_contains is not None:
params.append(("trace_id_contains", trace_id_contains))
payload["trace_id_contains"] = trace_id_contains
if span_id is not None:
params.append(("span_id", span_id))
payload["span_id"] = span_id
if span_id_contains is not None:
params.append(("span_id_contains", span_id_contains))
payload["span_id_contains"] = span_id_contains
if parent_id is not None:
params.append(("parent_id", parent_id))
payload["parent_id"] = parent_id
if parent_id_contains is not None:
params.append(("parent_id_contains", parent_id_contains))
payload["parent_id_contains"] = parent_id_contains
if name is not None:
params.append(("name", name))
payload["name"] = name
if name_contains is not None:
params.append(("name_contains", name_contains))
params.append(("filter_logic", filter_logic))
payload["name_contains"] = name_contains
payload["filter_logic"] = filter_logic
if sort_by is not None:
params.append(("sort_by", sort_by))
params.append(("sort_order", sort_order))
params.append(("limit", limit))
params.append(("offset", offset))
data = await self._request_json("get", "/spans", params=params)
payload["sort_by"] = sort_by
payload["sort_order"] = sort_order
data = await self._request_json("post", "/spans/search", json=payload)
items = [Span.model_validate(item) for item in data["items"]]
return PaginatedResult(items=items, limit=data["limit"], offset=data["offset"], total=data["total"])
@@ -1888,21 +2056,17 @@ class LightningStoreClient(LightningStore):
limit: int = -1,
offset: int = 0,
) -> PaginatedResult[Worker]:
params: List[Tuple[str, Any]] = [
("limit", limit),
("offset", offset),
]
payload: Dict[str, Any] = {}
if status_in is not None:
for value in status_in:
params.append(("status_in", value))
payload["status_in"] = status_in
if worker_id_contains is not None:
params.append(("worker_id_contains", worker_id_contains))
params.append(("filter_logic", filter_logic))
payload["worker_id_contains"] = worker_id_contains
payload["filter_logic"] = filter_logic
if sort_by is not None:
params.append(("sort_by", sort_by))
params.append(("sort_order", sort_order))
payload["sort_by"] = sort_by
payload["sort_order"] = sort_order
data = await self._request_json("get", "/workers", params=params)
data = await self._request_json("post", "/workers/search", json=payload)
items = [Worker.model_validate(item) for item in data.get("items", [])]
return PaginatedResult(items=items, limit=data["limit"], offset=data["offset"], total=data["total"])
+13 -1
View File
@@ -1,9 +1,21 @@
# Copyright (c) Microsoft. All rights reserved.
from .base import Collection, FilterOptions, KeyValue, LightningCollections, PaginatedResult, Queue, SortOptions
from .base import (
AtomicLabels,
AtomicMode,
Collection,
FilterOptions,
KeyValue,
LightningCollections,
PaginatedResult,
Queue,
SortOptions,
)
from .memory import DequeBasedQueue, DictBasedKeyValue, InMemoryLightningCollections, ListBasedCollection
__all__ = [
"AtomicLabels",
"AtomicMode",
"Collection",
"Queue",
"KeyValue",
+243 -12
View File
@@ -2,6 +2,10 @@
from __future__ import annotations
import functools
import time
from contextlib import asynccontextmanager
from numbers import Real
from typing import (
TYPE_CHECKING,
Any,
@@ -18,10 +22,14 @@ from typing import (
Sequence,
Tuple,
Type,
TypeGuard,
TypeVar,
cast,
)
from agentlightning.store.utils import LATENCY_BUCKETS
from agentlightning.utils.metrics import MetricsBackend
if TYPE_CHECKING:
from typing import Self
@@ -40,10 +48,146 @@ from agentlightning.types import (
T = TypeVar("T") # Recommended to be a BaseModel
K = TypeVar("K")
V = TypeVar("V")
T_callable = TypeVar("T_callable", bound=Callable[..., Any])
AtomicMode = Literal["r", "w", "rw"]
"""What is expected within the atomic context. Can be "read", "write", or "read-write"."""
AtomicLabels = Literal[
"rollouts", "attempts", "spans", "resources", "workers", "rollout_queue", "span_sequence_ids", "generic"
]
"""Labels for atomic operations.
These labels are used to identify the collections that are affected by the atomic operation.
The `generic` label is used to identify atomic operations that are not associated with any specific collection.
"""
class Collection(Generic[T]):
"""Behaves like a list of items. Supporting addition, updating, and deletion of items."""
def resolve_error_type(exc: BaseException | None) -> str:
if exc is None:
return "N/A"
try:
from .mongo import resolve_mongo_error_type
error_type = resolve_mongo_error_type(exc)
if error_type is not None:
return error_type
except ImportError:
# If the mongo backend is not available, fall back to using the exception's class name.
pass
return exc.__class__.__name__
def tracked(operation: str):
"""Decorator to track the execution of the decorated method."""
def decorator(func: T_callable) -> T_callable:
@functools.wraps(func)
async def wrapper(self: TrackedCollection, *args: Any, **kwargs: Any) -> Any:
async with self.tracking_context(operation, self.collection_name):
return await func(self, *args, **kwargs)
return cast(T_callable, wrapper)
return decorator
def ensure_numeric(value: Any, *, description: str) -> TypeGuard[Real]:
"""Validate that *value* behaves like a real number.
Returns true or crashes.
"""
if isinstance(value, bool):
raise TypeError(f"{description} must be numeric; got bool")
if not isinstance(value, Real):
raise TypeError(f"{description} must be numeric; got {type(value).__name__}")
return True
class DuplicatedPrimaryKeyError(ValueError):
"""Error raised when a duplicate key is encountered."""
pass
class TrackedCollection:
"""An object that can be tracked by the metrics backend."""
def __init__(self, tracker: MetricsBackend | None = None):
self._tracker = tracker
@property
def tracker(self) -> MetricsBackend | None:
return self._tracker
@property
def collection_name(self) -> str:
"""The identifier of the collection."""
raise NotImplementedError()
@property
def extra_tracking_labels(self) -> Mapping[str, Any]:
"""Extra labels to add to the tracking context."""
return {}
@asynccontextmanager
async def tracking_context(self, operation: str, collection: str):
"""Context manager to track the execution of the decorated method.
Args:
operation: The operation to track.
collection: The collection to track.
"""
if self._tracker is None:
# no-op context manager
yield
else:
from agentlightning.store.collection_based import nearest_lightning_store_method_from_stack
# Enable tracking
start_time = time.perf_counter()
status: str = "OK"
public_store_method, private_store_method = nearest_lightning_store_method_from_stack()
try:
yield
except BaseException as exc:
status = resolve_error_type(exc)
raise
finally:
elapsed = time.perf_counter() - start_time
await self._tracker.inc_counter( # pyright: ignore[reportPrivateUsage]
"agl.collections.total",
labels={
"store_pubmeth": public_store_method,
"store_privmeth": private_store_method,
"operation": operation,
"collection": collection,
"status": status,
**self.extra_tracking_labels,
},
)
await self._tracker.observe_histogram( # pyright: ignore[reportPrivateUsage]
"agl.collections.latency",
value=elapsed,
labels={
"store_pubmeth": public_store_method,
"store_privmeth": private_store_method,
"operation": operation,
"collection": collection,
"status": status,
**self.extra_tracking_labels,
},
)
class Collection(TrackedCollection, Generic[T]):
"""Standard collection interface. Behaves like a list of items. Supporting addition, updating, and deletion of items."""
def primary_keys(self) -> Sequence[str]:
"""Get the primary keys of the collection."""
@@ -114,19 +258,42 @@ class Collection(Generic[T]):
"""
raise NotImplementedError()
async def update(self, items: Sequence[T]) -> None:
async def update(self, items: Sequence[T], update_fields: Sequence[str] | None = None) -> Sequence[T]:
"""Update the given items in the collection.
Args:
items: The items to update in the collection.
update_fields: The fields to update. If not provided, all fields in the type will be updated.
Only applicable if the item type is a Pydantic BaseModel.
Raises:
ValueError: If an item with the primary keys does not exist.
Returns:
The items that were updated.
"""
raise NotImplementedError()
async def upsert(self, items: Sequence[T]) -> None:
async def upsert(self, items: Sequence[T], update_fields: Sequence[str] | None = None) -> Sequence[T]:
"""Upsert the given items into the collection.
If the items with the same primary keys already exist, they will be updated.
Otherwise, they will be inserted.
The operation has three semantics configurable via `update_fields`:
- `update_or_insert` via `collection.upsert(items, update_fields=["status", "updated_at"])`.
If the item with the same primary keys already exists, only the specified fields will be updated.
Otherwise, the item will be inserted.
- `get_or_insert` via `collection.upsert(items, update_fields=[])`.
If the item with the same primary keys already exists, the item will be left unchanged.
Otherwise, the item will be inserted.
- `replace_ish` via `collection.upsert(items)`.
If the item with the same primary keys already exists, all fields from the item will be set.
Otherwise, the item will be inserted.
Returns:
The items that were upserted.
"""
raise NotImplementedError()
@@ -142,7 +309,7 @@ class Collection(Generic[T]):
raise NotImplementedError()
class Queue(Generic[T]):
class Queue(TrackedCollection, Generic[T]):
"""Behaves like a deque. Supporting appending items to the end and popping items from the front."""
def __repr__(self) -> str:
@@ -196,7 +363,7 @@ class Queue(Generic[T]):
raise NotImplementedError()
class KeyValue(Generic[K, V]):
class KeyValue(TrackedCollection, Generic[K, V]):
"""Behaves like a dictionary. Supporting addition, updating, and deletion of items."""
def __repr__(self) -> str:
@@ -214,6 +381,22 @@ class KeyValue(Generic[K, V]):
"""Set the value for the given key."""
raise NotImplementedError()
async def inc(self, key: K, amount: V) -> V:
"""Increase the numeric value for the given key by `amount` and return the new value.
Raises:
TypeError: If the existing value or `amount` is not numeric.
"""
raise NotImplementedError()
async def chmax(self, key: K, value: V) -> V:
"""Set the value for the given key to the maximum of the current and new value.
Raises:
TypeError: If the existing value or `value` is not numeric.
"""
raise NotImplementedError()
async def pop(self, key: K, default: V | None = None) -> V | None:
"""Pop the value for the given key, or the default value if the key is not found."""
raise NotImplementedError()
@@ -223,13 +406,35 @@ class KeyValue(Generic[K, V]):
raise NotImplementedError()
class LightningCollections:
class LightningCollections(TrackedCollection):
"""Collections of rollouts, attempts, spans, resources, and workers.
[LightningStore][agentlightning.LightningStore] implementations can use this as a storage base
to implement the store API.
"""
def __init__(self, tracker: MetricsBackend | None = None, extra_labels: Optional[Sequence[str]] = None):
super().__init__(tracker=tracker)
self.register_collection_metrics(extra_labels)
def register_collection_metrics(self, extra_labels: Optional[Sequence[str]] = None) -> None:
if self._tracker is None:
return
labels = ["store_pubmeth", "operation", "collection", "store_privmeth", "status"]
if extra_labels is not None:
labels.extend(extra_labels)
self._tracker.register_histogram(
"agl.collections.latency",
labels,
buckets=LATENCY_BUCKETS,
group_level=2,
)
self._tracker.register_counter("agl.collections.total", labels, group_level=2)
@property
def tracker(self) -> MetricsBackend | None:
return self._tracker
@property
def rollouts(self) -> Collection[Rollout]:
"""Collections of rollouts."""
@@ -265,20 +470,46 @@ class LightningCollections:
"""Dictionary (counter) of span sequence IDs."""
raise NotImplementedError()
def atomic(self, *args: Any, **kwargs: Any) -> AsyncContextManager[Self]:
def atomic(
self,
*,
mode: AtomicMode = "rw",
snapshot: bool = False,
commit: bool = False,
labels: Optional[Sequence[AtomicLabels]] = None,
**kwargs: Any,
) -> AsyncContextManager[Self]:
"""Perform a atomic operation on the collections.
Subclass may use args and kwargs to support multiple levels of atomicity.
The arguments can be seen as tags. They only imply the behavior of the operation, not the implementation.
Args:
*args: Arguments to pass to the operation.
mode: The mode of atomicity. See [`AtomicMode`][agentlightning.store.collection.AtomicMode].
snapshot: Enable read snapshot for repeatable reads. Data consistency is guaranteed. The real behavior is implementation-dependent.
commit: Enable commitment for write operations. Unsuccessful operations will be rolled back depending on the implementation.
Recommend to use [`execute()`][agentlightning.store.collection.LightningCollections.execute] for this level to enable automatic retries.
Remember that the real behavior is implementation-dependent.
labels: Labels to add to the atomic operation (commonly used as lock names or collection names).
**kwargs: Keyword arguments to pass to the operation.
"""
raise NotImplementedError()
async def execute(self, callback: Callable[[Self], Awaitable[T]]) -> T:
"""Execute the given callback within an atomic operation."""
async with self.atomic() as collections:
async def execute(
self,
callback: Callable[[Self], Awaitable[T]],
*,
mode: AtomicMode = "rw",
snapshot: bool = False,
commit: bool = False,
labels: Optional[Sequence[AtomicLabels]] = None,
**kwargs: Any,
) -> T:
"""Execute the given callback within an atomic operation. Retry on transient errors is implied.
See [`atomic()`][agentlightning.store.collection.LightningCollections.atomic] for more details.
"""
async with self.atomic(mode=mode, snapshot=snapshot, commit=commit, labels=labels, **kwargs) as collections:
return await callback(collections)
+243 -29
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
import asyncio
import logging
import uuid
import weakref
from collections import deque
from contextlib import asynccontextmanager
from contextlib import AsyncExitStack, asynccontextmanager
from typing import (
Any,
Deque,
@@ -22,8 +23,12 @@ from typing import (
Type,
TypeVar,
Union,
cast,
)
import aiologic
from pydantic import BaseModel
from agentlightning.types import (
Attempt,
FilterField,
@@ -35,15 +40,21 @@ from agentlightning.types import (
Span,
Worker,
)
from agentlightning.utils.metrics import MetricsBackend
from .base import (
AtomicLabels,
AtomicMode,
Collection,
DuplicatedPrimaryKeyError,
FilterMap,
KeyValue,
LightningCollections,
Queue,
ensure_numeric,
normalize_filter_options,
resolve_sort_options,
tracked,
)
T = TypeVar("T") # Recommended to be a BaseModel, not a dict
@@ -186,10 +197,19 @@ class ListBasedCollection(Collection[T]):
if the field is str-like, 0 if the field is int-like, 0.0 if the field is float-like.
"""
def __init__(self, items: List[T], item_type: Type[T], primary_keys: Sequence[str]):
def __init__(
self,
items: List[T],
item_type: Type[T],
primary_keys: Sequence[str],
id: Optional[str] = None,
tracker: Optional[MetricsBackend] = None,
):
super().__init__(tracker=tracker)
if not primary_keys:
raise ValueError("primary_keys must be non-empty")
self._id = id if id is not None else str(uuid.uuid4())
self._items: Dict[Any, Any] = {}
self._size: int = 0
if issubclass(item_type, dict):
@@ -201,6 +221,10 @@ class ListBasedCollection(Collection[T]):
for item in items or []:
self._mutate_single(item, mode="insert")
@property
def collection_name(self) -> str:
return self._id
def primary_keys(self) -> Sequence[str]:
"""Return the primary key field names for this collection."""
return self._primary_keys
@@ -282,7 +306,7 @@ class ListBasedCollection(Collection[T]):
# We should always return inside the loop.
raise RuntimeError("Unreachable")
def _mutate_single(self, item: T, mode: MutationMode) -> None:
def _mutate_single(self, item: T, mode: MutationMode, update_fields: Sequence[str] | None = None) -> Optional[T]:
"""Core mutation logic shared by insert, update, upsert, and delete."""
self._ensure_item_type(item)
key_values = self._extract_primary_key_values(item)
@@ -293,13 +317,43 @@ class ListBasedCollection(Collection[T]):
if mode == "insert":
if exists:
raise ValueError(f"Item already exists with primary key(s): {self._render_key_values(key_values)}")
raise DuplicatedPrimaryKeyError(
f"Item already exists with primary key(s): {self._render_key_values(key_values)}"
)
parent[final_key] = item
self._size += 1
else: # upsert
if not exists:
self._size += 1
parent[final_key] = item
parent[final_key] = item
elif update_fields is None:
# update_or_insert: update all fields
parent[final_key] = item
else:
if not issubclass(self._item_type, BaseModel):
raise TypeError(
f"When using update_fields, the item type must be a Pydantic BaseModel, got {self._item_type.__name__}"
)
# Try to fetch the existing item
existing = parent[final_key]
if not isinstance(existing, self._item_type):
raise ValueError(
f"Internal structure corrupted: expected {self._item_type.__name__}, got {type(existing)!r}"
)
if not isinstance(item, self._item_type):
raise TypeError(
f"When using update_fields, the item type must be a Pydantic BaseModel, got {type(item).__name__}"
)
parent[final_key] = parent[final_key].model_copy(
update={field: getattr(item, field) for field in update_fields}
)
return parent[final_key]
elif mode in ("update", "delete"):
# For update/delete we must not create missing paths.
@@ -314,7 +368,22 @@ class ListBasedCollection(Collection[T]):
raise ValueError(f"Item does not exist with primary key(s): {self._render_key_values(key_values)}")
if mode == "update":
parent[final_key] = item
if update_fields is None:
# replace the entire item
parent[final_key] = item
else:
if not issubclass(self._item_type, BaseModel):
raise TypeError(
f"When using update_fields, the item type must be a Pydantic BaseModel, got {self._item_type.__name__}"
)
if not isinstance(item, self._item_type):
raise TypeError(
f"When using update_fields, the item type must be a Pydantic BaseModel, got {type(item).__name__}"
)
parent[final_key] = parent[final_key].model_copy(
update={field: getattr(item, field) for field in update_fields}
)
return parent[final_key]
else: # delete
del parent[final_key]
self._size -= 1
@@ -434,6 +503,7 @@ class ListBasedCollection(Collection[T]):
# No items exist for this primary-key prefix.
return ()
@tracked("query")
async def query(
self,
filter: Optional[FilterOptions] = None,
@@ -497,6 +567,7 @@ class ListBasedCollection(Collection[T]):
total=total_matched,
)
@tracked("get")
async def get(
self,
filter: Optional[FilterOptions] = None,
@@ -533,11 +604,12 @@ class ListBasedCollection(Collection[T]):
return best_item
@tracked("insert")
async def insert(self, items: Sequence[T]) -> None:
"""Insert the given items.
Raises:
ValueError: If any item with the same primary keys already exists.
DuplicatedPrimaryKeyError: If any item with the same primary keys already exists.
"""
seen_keys: set[Tuple[Any, ...]] = set()
prepared: List[T] = []
@@ -545,8 +617,8 @@ class ListBasedCollection(Collection[T]):
self._ensure_item_type(item)
key_values = self._extract_primary_key_values(item)
if key_values in seen_keys:
raise ValueError(
f"Insert payload contains duplicate primary key(s): {self._render_key_values(key_values)}"
raise DuplicatedPrimaryKeyError(
f"Insert payload contains duplicated primary key(s): {self._render_key_values(key_values)}"
)
seen_keys.add(key_values)
prepared.append(item)
@@ -554,20 +626,33 @@ class ListBasedCollection(Collection[T]):
for item in prepared:
self._mutate_single(item, mode="insert")
async def update(self, items: Sequence[T]) -> None:
@tracked("update")
async def update(self, items: Sequence[T], update_fields: Sequence[str] | None = None) -> Sequence[T]:
"""Update the given items.
Raises:
ValueError: If any item with the given primary keys does not exist.
"""
updated_items: List[T] = []
for item in items:
self._mutate_single(item, mode="update")
updated = self._mutate_single(item, mode="update", update_fields=update_fields)
if updated is None:
raise RuntimeError(f"_mutate_single returned None for item {item}. This should never happen.")
updated_items.append(updated)
return updated_items
async def upsert(self, items: Sequence[T]) -> None:
@tracked("upsert")
async def upsert(self, items: Sequence[T], update_fields: Sequence[str] | None = None) -> Sequence[T]:
"""Upsert the given items (insert if missing, otherwise update)."""
upserted_items: List[T] = []
for item in items:
self._mutate_single(item, mode="upsert")
upserted = self._mutate_single(item, mode="upsert", update_fields=update_fields)
if upserted is None:
raise RuntimeError(f"_mutate_single returned None for item {item}. This should never happen.")
upserted_items.append(upserted)
return upserted_items
@tracked("delete")
async def delete(self, items: Sequence[T]) -> None:
"""Delete the given items.
@@ -587,23 +672,37 @@ class DequeBasedQueue(Queue[T]):
Provides O(1) amortized enqueue (append) and dequeue (popleft).
"""
def __init__(self, item_type: Type[T], items: Optional[Sequence[T]] = None):
def __init__(
self,
item_type: Type[T],
items: Optional[Sequence[T]] = None,
id: Optional[str] = None,
tracker: Optional[MetricsBackend] = None,
):
super().__init__(tracker=tracker)
self._items: Deque[T] = deque()
self._item_type: Type[T] = item_type
self._id = id if id is not None else str(uuid.uuid4())
if items:
self._items.extend(items)
def item_type(self) -> Type[T]:
return self._item_type
@property
def collection_name(self) -> str:
return self._id
def __repr__(self) -> str:
return f"<{self.__class__.__name__}[{self.item_type().__name__}] ({len(self._items)})>"
@tracked("has")
async def has(self, item: T) -> bool:
if not isinstance(item, self._item_type):
raise TypeError(f"Expected item of type {self._item_type.__name__}, got {type(item).__name__}")
return item in self._items
@tracked("enqueue")
async def enqueue(self, items: Sequence[T]) -> Sequence[T]:
for item in items:
if not isinstance(item, self._item_type):
@@ -611,6 +710,7 @@ class DequeBasedQueue(Queue[T]):
self._items.append(item)
return items
@tracked("dequeue")
async def dequeue(self, limit: int = 1) -> Sequence[T]:
if limit <= 0:
return []
@@ -619,6 +719,7 @@ class DequeBasedQueue(Queue[T]):
out.append(self._items.popleft())
return out
@tracked("peek")
async def peek(self, limit: int = 1) -> Sequence[T]:
if limit <= 0:
return []
@@ -630,6 +731,7 @@ class DequeBasedQueue(Queue[T]):
result.append(item)
return result
@tracked("size")
async def size(self) -> int:
return len(self._items)
@@ -637,21 +739,61 @@ class DequeBasedQueue(Queue[T]):
class DictBasedKeyValue(KeyValue[K, V]):
"""KeyValue implementation backed by a plain dictionary."""
def __init__(self, data: Optional[Mapping[K, V]] = None):
def __init__(
self, data: Optional[Mapping[K, V]] = None, id: Optional[str] = None, tracker: Optional[MetricsBackend] = None
):
super().__init__(tracker=tracker)
self._values: Dict[K, V] = dict(data) if data else {}
self._id = id if id is not None else str(uuid.uuid4())
@property
def collection_name(self) -> str:
return self._id
@tracked("has")
async def has(self, key: K) -> bool:
return key in self._values
@tracked("get")
async def get(self, key: K, default: V | None = None) -> V | None:
return self._values.get(key, default)
@tracked("set")
async def set(self, key: K, value: V) -> None:
self._values[key] = value
@tracked("inc")
async def inc(self, key: K, amount: V) -> V:
assert ensure_numeric(amount, description="amount")
if key in self._values:
current_value = self._values[key]
assert ensure_numeric(current_value, description=f"value for key {key!r}")
new_value = cast(V, current_value + amount)
self._values[key] = new_value
else:
new_value = amount
self._values[key] = new_value
return new_value
@tracked("chmax")
async def chmax(self, key: K, value: V) -> V:
assert ensure_numeric(value, description="value")
if key in self._values:
current_value = self._values[key]
assert ensure_numeric(current_value, description=f"value for key {key!r}")
if value > current_value:
self._values[key] = value
return value
return current_value
else:
self._values[key] = value
return value
@tracked("pop")
async def pop(self, key: K, default: V | None = None) -> V | None:
return self._values.pop(key, default)
@tracked("size")
async def size(self) -> int:
return len(self._values)
@@ -662,17 +804,41 @@ class InMemoryLightningCollections(LightningCollections):
Serves as the storage base for [`InMemoryLightningStore`][agentlightning.InMemoryLightningStore].
"""
def __init__(self):
self._lock = _LoopAwareAsyncLock()
self._rollouts = ListBasedCollection(items=[], item_type=Rollout, primary_keys=["rollout_id"])
self._attempts = ListBasedCollection(items=[], item_type=Attempt, primary_keys=["rollout_id", "attempt_id"])
self._spans = ListBasedCollection(
items=[], item_type=Span, primary_keys=["rollout_id", "attempt_id", "span_id"]
def __init__(self, lock_type: Literal["thread", "asyncio"], tracker: MetricsBackend | None = None):
super().__init__(tracker=tracker)
self._lock: Mapping[AtomicLabels, _LoopAwareAsyncLock | _ThreadSafeAsyncLock] = {
"rollouts": _LoopAwareAsyncLock() if lock_type == "asyncio" else _ThreadSafeAsyncLock(),
"attempts": _LoopAwareAsyncLock() if lock_type == "asyncio" else _ThreadSafeAsyncLock(),
"spans": _LoopAwareAsyncLock() if lock_type == "asyncio" else _ThreadSafeAsyncLock(),
"resources": _LoopAwareAsyncLock() if lock_type == "asyncio" else _ThreadSafeAsyncLock(),
"workers": _LoopAwareAsyncLock() if lock_type == "asyncio" else _ThreadSafeAsyncLock(),
"rollout_queue": _LoopAwareAsyncLock() if lock_type == "asyncio" else _ThreadSafeAsyncLock(),
"span_sequence_ids": _LoopAwareAsyncLock() if lock_type == "asyncio" else _ThreadSafeAsyncLock(),
"generic": _LoopAwareAsyncLock() if lock_type == "asyncio" else _ThreadSafeAsyncLock(),
}
self._rollouts = ListBasedCollection(
items=[], item_type=Rollout, primary_keys=["rollout_id"], id="rollouts", tracker=tracker
)
self._resources = ListBasedCollection(items=[], item_type=ResourcesUpdate, primary_keys=["resources_id"])
self._workers = ListBasedCollection(items=[], item_type=Worker, primary_keys=["worker_id"])
self._rollout_queue = DequeBasedQueue(items=[], item_type=str)
self._span_sequence_ids = DictBasedKeyValue[str, int](data={}) # rollout_id -> sequence_id
self._attempts = ListBasedCollection(
items=[], item_type=Attempt, primary_keys=["rollout_id", "attempt_id"], id="attempts", tracker=tracker
)
self._spans = ListBasedCollection(
items=[], item_type=Span, primary_keys=["rollout_id", "attempt_id", "span_id"], id="spans", tracker=tracker
)
self._resources = ListBasedCollection(
items=[], item_type=ResourcesUpdate, primary_keys=["resources_id"], id="resources", tracker=tracker
)
self._workers = ListBasedCollection(
items=[], item_type=Worker, primary_keys=["worker_id"], id="workers", tracker=tracker
)
self._rollout_queue = DequeBasedQueue(items=[], item_type=str, id="rollout_queue", tracker=tracker)
self._span_sequence_ids = DictBasedKeyValue[str, int](
data={}, id="span_sequence_ids", tracker=tracker
) # rollout_id -> sequence_id
@property
def collection_name(self) -> str:
return "router"
@property
def rollouts(self) -> ListBasedCollection[Rollout]:
@@ -703,11 +869,41 @@ class InMemoryLightningCollections(LightningCollections):
return self._span_sequence_ids
@asynccontextmanager
async def atomic(self, *args: Any, **kwargs: Any):
"""In-memory collections apply a lock outside. It doesn't need to manipulate the collections inside."""
async with self._lock:
yield self
async def atomic(
self,
*,
mode: AtomicMode = "rw",
snapshot: bool = False,
labels: Optional[Sequence[AtomicLabels]] = None,
**kwargs: Any,
):
"""In-memory collections apply a lock outside. It doesn't need to manipulate the collections inside.
Skip the locking if mode is "r" and snapshot is False.
This collection implementation does NOT support rollback / commit.
"""
if mode == "r" and not snapshot:
yield self
return
if not labels:
# If no labels are provided, use all locks.
labels = list(self._lock.keys())
# IMPORTANT: Sort the labels to ensure consistent locking order.
# This is necessary to avoid deadlocks when multiple threads/coroutines
# are trying to acquire the same locks in different orders.
labels = sorted(labels)
async with self.tracking_context(operation="atomic", collection=self.collection_name):
managers = [(label, self._lock[label]) for label in labels]
async with AsyncExitStack() as stack:
for label, manager in managers:
async with self.tracking_context(operation="lock", collection=label):
await stack.enter_async_context(manager)
yield self
@tracked("evict_spans_for_rollout")
async def evict_spans_for_rollout(self, rollout_id: str) -> None:
"""Evict all spans for a given rollout ID.
@@ -754,3 +950,21 @@ class _LoopAwareAsyncLock:
if lock is None or not lock.locked():
raise RuntimeError("Lock released without being acquired")
lock.release()
class _ThreadSafeAsyncLock:
"""A thread lock powered by aiologic that can be used in both async and sync contexts.
aiologic claims itself to be a thread-safe asyncio lock.
"""
def __init__(self):
self._lock = aiologic.Lock()
async def __aenter__(self):
await self._lock.async_acquire()
return self
async def __aexit__(self, *args: Any, **kwargs: Any):
# .release() is non-blocking, so we can call it directly
self._lock.async_release()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+104 -56
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
import logging
import sys
import threading
from collections.abc import Iterable
from collections.abc import Mapping as MappingABC
from typing import (
@@ -19,14 +18,17 @@ from typing import (
Optional,
Sequence,
Set,
Tuple,
TypeVar,
Union,
cast,
)
import aiologic
from pydantic import BaseModel
from agentlightning.types import AttemptedRollout, PaginatedResult, Rollout, Span
from agentlightning.types import AttemptedRollout, NamedResources, PaginatedResult, ResourcesUpdate, Rollout, Span
from agentlightning.utils.metrics import MetricsBackend
from .base import UNSET, LightningStoreCapabilities, LightningStoreStatistics, Unset, is_finished, is_running
from .collection import InMemoryLightningCollections
@@ -71,24 +73,35 @@ class InMemoryLightningStore(CollectionBasedLightningStore[InMemoryLightningColl
Thread-safe and async-compatible but data is not persistent.
Args:
thread_safe: Whether the store is thread-safe.
eviction_memory_threshold: The threshold for evicting spans in bytes.
By default, it's 70% of the total VRAM available.
safe_memory_threshold: The threshold for safe memory usage in bytes.
By default, it's 80% of the eviction threshold.
span_size_estimator: A function to estimate the size of a span in bytes.
By default, it's a simple size estimator that uses sys.getsizeof.
tracker: The metrics tracker to use.
scan_debounce_seconds: The debounce time for the scan for unhealthy rollouts.
Set to 0 to disable debouncing.
"""
def __init__(
self,
*,
thread_safe: bool = False,
eviction_memory_threshold: float | int | None = None,
safe_memory_threshold: float | int | None = None,
span_size_estimator: Callable[[Span], int] | None = None,
prometheus: bool = False,
tracker: MetricsBackend | None = None,
scan_debounce_seconds: float = 10.0,
):
super().__init__(collections=InMemoryLightningCollections(), prometheus=prometheus)
super().__init__(
collections=InMemoryLightningCollections(lock_type="thread" if thread_safe else "asyncio", tracker=tracker),
tracker=tracker,
scan_debounce_seconds=scan_debounce_seconds,
)
self._thread_safe = thread_safe
self._start_time_by_rollout: Dict[str, float] = {}
self._span_bytes_by_rollout: Dict[str, int] = Counter()
self._total_span_bytes: int = 0
@@ -122,7 +135,7 @@ class InMemoryLightningStore(CollectionBasedLightningStore[InMemoryLightningColl
self._custom_span_size_estimator = span_size_estimator
# Completion tracking for wait_for_rollouts (cross-loop safe)
self._completion_events: Dict[str, threading.Event] = {}
self._completion_events: Dict[str, aiologic.Event] = {}
# Running rollouts cache, including preparing and running rollouts
self._running_rollout_ids: Set[str] = set()
@@ -134,7 +147,7 @@ class InMemoryLightningStore(CollectionBasedLightningStore[InMemoryLightningColl
def capabilities(self) -> LightningStoreCapabilities:
"""Return the capabilities of the store."""
return LightningStoreCapabilities(
thread_safe=False,
thread_safe=self._thread_safe,
async_safe=True,
zero_copy=False,
otlp_traces=False,
@@ -153,7 +166,7 @@ class InMemoryLightningStore(CollectionBasedLightningStore[InMemoryLightningColl
@tracked("wait_for_rollout")
async def wait_for_rollout(self, rollout_id: str, timeout: Optional[float] = None) -> Optional[Rollout]:
"""Wait for a specific rollout to complete with a timeout."""
async with self.collections.atomic() as collections:
async with self.collections.atomic(mode="r", snapshot=self._read_snapshot, labels=["rollouts"]) as collections:
rollout = await collections.rollouts.get({"rollout_id": {"exact": rollout_id}})
if rollout and is_finished(rollout):
return rollout
@@ -181,47 +194,82 @@ class InMemoryLightningStore(CollectionBasedLightningStore[InMemoryLightningColl
# If event was set (not timeout), check if rollout is finished
if result:
async with self.collections.atomic() as collections:
async with self.collections.atomic(
mode="r", snapshot=self._read_snapshot, labels=["rollouts"]
) as collections:
rollout = await collections.rollouts.get({"rollout_id": {"exact": rollout_id}})
if rollout and is_finished(rollout):
return rollout
return None
@tracked("on_rollout_update")
async def on_rollout_update(self, rollout: Rollout) -> None:
@tracked("add_resources_inmemory")
async def add_resources(self, resources: NamedResources) -> ResourcesUpdate:
ret = await super().add_resources(resources)
async with self.collections.atomic(mode="rw", snapshot=self._read_snapshot, labels=["resources"]):
self._latest_resources_id = ret.resources_id
return ret
@tracked("update_resources_inmemory")
async def update_resources(self, resources_id: str, resources: NamedResources) -> ResourcesUpdate:
ret = await super().update_resources(resources_id, resources)
async with self.collections.atomic(mode="rw", snapshot=self._read_snapshot, labels=["resources"]):
self._latest_resources_id = ret.resources_id
return ret
@tracked("_post_update_rollout_inmemory")
async def _post_update_rollout(
self, rollouts: Sequence[Tuple[Rollout, Sequence[str]]], skip_enqueue: bool = False
) -> None:
"""Update the running rollout ids set when the rollout updates."""
if is_running(rollout):
self._running_rollout_ids.add(rollout.rollout_id)
else:
self._running_rollout_ids.discard(rollout.rollout_id)
await super()._post_update_rollout(rollouts, skip_enqueue=skip_enqueue)
async with self.collections.atomic(mode="rw", snapshot=self._read_snapshot, labels=["rollouts"]):
for rollout, _ in rollouts:
if is_running(rollout):
self._running_rollout_ids.add(rollout.rollout_id)
else:
self._running_rollout_ids.discard(rollout.rollout_id)
if is_finished(rollout):
self._completion_events.setdefault(rollout.rollout_id, threading.Event())
self._completion_events[rollout.rollout_id].set()
else:
self._completion_events.setdefault(rollout.rollout_id, threading.Event())
# Rollout status can never transition from finished to running (unlike attempt)
# so we don't need to clear the completion event even in case of retrying.
if is_finished(rollout):
self._completion_events.setdefault(rollout.rollout_id, aiologic.Event())
self._completion_events[rollout.rollout_id].set()
else:
self._completion_events.setdefault(rollout.rollout_id, aiologic.Event())
# Rollout status can never transition from finished to running (unlike attempt)
# so we don't need to clear the completion event even in case of retrying.
if rollout.rollout_id not in self._start_time_by_rollout:
self._start_time_by_rollout[rollout.rollout_id] = rollout.start_time
if rollout.rollout_id not in self._start_time_by_rollout:
self._start_time_by_rollout[rollout.rollout_id] = rollout.start_time
@tracked("get_running_rollouts")
async def get_running_rollouts(self, collections: InMemoryLightningCollections) -> List[AttemptedRollout]:
"""Accelerated version of `get_running_rollouts` for in-memory store. Used for healthcheck."""
rollouts = await collections.rollouts.query(filter={"rollout_id": {"within": list(self._running_rollout_ids)}})
running_rollouts: List[AttemptedRollout] = []
for rollout in rollouts.items:
latest_attempt = await collections.attempts.get(
filter={"rollout_id": {"exact": rollout.rollout_id}},
sort={"name": "sequence_id", "order": "desc"},
)
if not latest_attempt:
# The rollout is running but has no attempts, this should not happen
logger.error(f"Rollout {rollout.rollout_id} is running but has no attempts")
continue
running_rollouts.append(AttemptedRollout(**rollout.model_dump(), attempt=latest_attempt))
@tracked("_unlocked_query_rollouts_by_rollout_ids")
async def _unlocked_query_rollouts_by_rollout_ids(
self, collections: InMemoryLightningCollections, rollout_ids: Sequence[str]
) -> List[Rollout]:
"""Always use exact. This is faster than within filter for in-memory store."""
if len(rollout_ids) == 0:
return []
rollouts = [await collections.rollouts.get({"rollout_id": {"exact": rollout_id}}) for rollout_id in rollout_ids]
return [rollout for rollout in rollouts if rollout is not None]
@tracked("_unlocked_get_running_rollouts")
async def _unlocked_get_running_rollouts(self, collections: InMemoryLightningCollections) -> List[AttemptedRollout]:
"""Accelerated version of `_unlocked_get_running_rollouts` for in-memory store. Used for healthcheck."""
async with self.collections.atomic(
mode="r", snapshot=self._read_snapshot, labels=["rollouts", "attempts"]
) as collections:
rollouts = await self._unlocked_query_rollouts_by_rollout_ids(collections, list(self._running_rollout_ids))
running_rollouts: List[AttemptedRollout] = []
for rollout in rollouts:
latest_attempt = await collections.attempts.get(
filter={"rollout_id": {"exact": rollout.rollout_id}},
sort={"name": "sequence_id", "order": "desc"},
)
if not latest_attempt:
# The rollout is running but has no attempts, this should not happen
logger.error(f"Rollout {rollout.rollout_id} is running but has no attempts")
continue
running_rollouts.append(AttemptedRollout(**rollout.model_dump(), attempt=latest_attempt))
return running_rollouts
@tracked("query_spans_inmemory") # Since this method calls super, we need to track it separately
@@ -235,28 +283,28 @@ class InMemoryLightningStore(CollectionBasedLightningStore[InMemoryLightningColl
raise RuntimeError(f"Spans for rollout {rollout_id} have been evicted")
return await super().query_spans(rollout_id, attempt_id, **kwargs)
@tracked("_add_many_spans_unlocked_inmemory")
async def _add_many_spans_unlocked(
self, collections: InMemoryLightningCollections, rollout_id: str, attempt_id: str, spans: Sequence[Span]
) -> Sequence[Span]:
@tracked("_post_add_spans")
async def _post_add_spans(self, spans: Sequence[Span], rollout_id: str, attempt_id: str) -> None:
"""In-memory store needs to maintain the span data in memory, and evict spans when memory is low."""
inserted = await super()._add_many_spans_unlocked(collections, rollout_id, attempt_id, spans)
for span in inserted:
await self._account_span_size(span)
await self._maybe_evict_spans(collections)
await super()._post_add_spans(spans, rollout_id, attempt_id)
async with self.collections.atomic(
mode="rw", snapshot=self._read_snapshot, labels=["rollouts", "spans"]
) as collections:
for span in spans:
await self._account_span_size(span)
await self._maybe_evict_spans(collections)
return inserted
@tracked("_get_latest_resources_id")
async def _get_latest_resources_id(self, collections: InMemoryLightningCollections) -> Optional[str]:
@tracked("_get_latest_resources_inmemory")
async def _get_latest_resources(self) -> Optional[ResourcesUpdate]:
if isinstance(self._latest_resources_id, Unset):
latest_resources = await collections.resources.get(sort={"name": "update_time", "order": "desc"})
if latest_resources:
self._latest_resources_id = latest_resources.resources_id
else:
self._latest_resources_id = None
return self._latest_resources_id
return await super()._get_latest_resources()
if self._latest_resources_id is not None:
async with self.collections.atomic(
mode="r", snapshot=self._read_snapshot, labels=["resources"]
) as collections:
return await collections.resources.get(filter={"resources_id": {"exact": self._latest_resources_id}})
return None
@staticmethod
def _resolve_memory_threshold(
+46 -42
View File
@@ -7,24 +7,13 @@ import hashlib
import logging
import time
import uuid
from typing import (
Any,
Callable,
Dict,
List,
Mapping,
Optional,
Sequence,
TypeVar,
Union,
)
from pymongo import AsyncMongoClient
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, TypeVar, Union
from agentlightning.types import Attempt, AttemptedRollout, Rollout
from agentlightning.utils.metrics import MetricsBackend
from .base import LightningStoreCapabilities, is_finished
from .collection.mongo import MongoClientPool, MongoLightningCollections, MongoOperationPrometheusTracker
from .collection.mongo import MongoClientPool, MongoLightningCollections
from .collection_based import CollectionBasedLightningStore, healthcheck_before, tracked
T_callable = TypeVar("T_callable", bound=Callable[..., Any])
@@ -42,27 +31,28 @@ class MongoLightningStore(CollectionBasedLightningStore[MongoLightningCollection
Data is persistent and can be shared between multiple processes.
Args:
client: The MongoDB client. Could be a string URI or an instance of AsyncMongoClient.
database: The MongoDB database. Could be a string name or an instance of AsyncDatabase.
You must provide at least one of client or database.
mongo_uri: MongoDB connection string (defaults to local replica set).
mongo_client_kwargs: Extra keyword arguments forwarded to `AsyncMongoClient`.
database: The MongoDB database name. Defaults to ``agentlightning``.
partition_id: The partition id. Useful when sharing the database among multiple Agent-lightning trainers.
tracker: The metrics tracker to use.
scan_debounce_seconds: The debounce time for the scan for unhealthy rollouts.
Set to 0 to disable debouncing.
"""
def __init__(
self,
*,
client: AsyncMongoClient[Mapping[str, Any]] | str,
mongo_uri: str = "mongodb://localhost:27017/?replicaSet=rs0",
mongo_client_kwargs: Mapping[str, Any] | None = None,
database_name: str | None = None,
partition_id: str | None = None,
prometheus: bool = False,
tracker: MetricsBackend | None = None,
scan_debounce_seconds: float = 10.0,
) -> None:
self._enable_prometheus = prometheus
self._auto_created_client = False
if isinstance(client, str):
self._client = AsyncMongoClient[Mapping[str, Any]](client)
self._auto_created_client = True
else:
self._client = client
self._mongo_uri = mongo_uri
self._mongo_client_kwargs = dict(mongo_client_kwargs or {})
if database_name is None:
database_name = "agentlightning"
logger.info("No database name provided, using default 'agentlightning'")
@@ -71,16 +61,20 @@ class MongoLightningStore(CollectionBasedLightningStore[MongoLightningCollection
partition_id = _generate_partition_id()
logger.info("No partition id provided, generated a new one: %s", partition_id)
self._client_pool = MongoClientPool(self._client)
self._client_pool = MongoClientPool[Mapping[str, Any]](
mongo_uri=self._mongo_uri,
mongo_client_kwargs=self._mongo_client_kwargs,
)
super().__init__(
collections=MongoLightningCollections(
self._client_pool,
database_name,
partition_id,
prometheus_tracker=MongoOperationPrometheusTracker(enabled=self._enable_prometheus),
tracker=tracker,
),
prometheus=self._enable_prometheus,
tracker=tracker,
scan_debounce_seconds=scan_debounce_seconds,
)
@property
@@ -96,9 +90,6 @@ class MongoLightningStore(CollectionBasedLightningStore[MongoLightningCollection
async def close(self) -> None:
"""Close the store by closing the client pool."""
await self._client_pool.close()
# If I created the client, I should close it too.
if self._auto_created_client:
await self._client.close()
@tracked("wait_for_rollouts")
@healthcheck_before
@@ -115,10 +106,13 @@ class MongoLightningStore(CollectionBasedLightningStore[MongoLightningCollection
unfinished_rollout_ids = set(rollout_ids)
while deadline is None or current_time <= deadline:
# Query the rollouts that are not finished in a single query
rollouts = await self.collections.rollouts.query(
filter={"rollout_id": {"within": list(unfinished_rollout_ids)}}
)
async with self.collections.atomic(
mode="r", snapshot=self._read_snapshot, labels=["rollouts"]
) as collections:
# Query the rollouts that are not finished in a single query
rollouts = await collections.rollouts.query(
filter={"rollout_id": {"within": list(unfinished_rollout_ids)}}
)
for rollout in rollouts.items:
if is_finished(rollout):
finished_rollouts[rollout.rollout_id] = rollout
@@ -133,18 +127,28 @@ class MongoLightningStore(CollectionBasedLightningStore[MongoLightningCollection
await asyncio.sleep(rest_time)
current_time = time.time()
# Logging will help debugging when there are stuck rollouts.
logger.debug(
"Waiting for rollouts. Number of finished rollouts: %d; number of unfinished rollouts: %d",
len(finished_rollouts),
len(unfinished_rollout_ids),
)
if len(unfinished_rollout_ids) < 30:
logger.debug("Unfinished rollouts: %s", unfinished_rollout_ids)
# Reorder the rollouts to match the input order
return [finished_rollouts[rollout_id] for rollout_id in rollout_ids if rollout_id in finished_rollouts]
@tracked("_many_rollouts_to_attempted_rollouts_unlocked")
async def _many_rollouts_to_attempted_rollouts_unlocked(
@tracked("_unlocked_many_rollouts_to_attempted_rollouts")
async def _unlocked_many_rollouts_to_attempted_rollouts(
self, collections: MongoLightningCollections, rollouts: Sequence[Rollout]
) -> List[Union[Rollout, AttemptedRollout]]:
"""Query the latest attempts for the rollouts, and attach them to the rollout objects."""
attempts = await collections.attempts.query(
filter={"rollout_id": {"within": [rollout.rollout_id for rollout in rollouts]}},
sort={"name": "sequence_id", "order": "desc"},
)
async with collections.atomic(mode="r", snapshot=self._read_snapshot, labels=["attempts"]) as collections:
attempts = await collections.attempts.query(
filter={"rollout_id": {"within": [rollout.rollout_id for rollout in rollouts]}},
sort={"name": "sequence_id", "order": "desc"},
)
latest_attempts: Dict[str, Attempt] = {}
for attempt in attempts:
if attempt.rollout_id not in latest_attempts:
+25 -3
View File
@@ -11,6 +11,7 @@ from agentlightning.types import (
Attempt,
AttemptedRollout,
AttemptStatus,
EnqueueRolloutRequest,
NamedResources,
ResourcesUpdate,
Rollout,
@@ -59,9 +60,17 @@ class LightningStoreThreaded(LightningStore):
resources_id: str | None = None,
config: RolloutConfig | None = None,
metadata: Dict[str, Any] | None = None,
worker_id: Optional[str] = None,
) -> AttemptedRollout:
with self._lock:
return await self.store.start_rollout(input, mode, resources_id, config, metadata)
return await self.store.start_rollout(
input,
mode,
resources_id,
config,
metadata,
worker_id,
)
async def enqueue_rollout(
self,
@@ -74,13 +83,26 @@ class LightningStoreThreaded(LightningStore):
with self._lock:
return await self.store.enqueue_rollout(input, mode, resources_id, config, metadata)
async def enqueue_many_rollouts(self, rollouts: Sequence[EnqueueRolloutRequest]) -> Sequence[Rollout]:
with self._lock:
return await self.store.enqueue_many_rollouts(rollouts)
async def dequeue_rollout(self, worker_id: Optional[str] = None) -> Optional[AttemptedRollout]:
with self._lock:
return await self.store.dequeue_rollout(worker_id=worker_id)
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
async def dequeue_many_rollouts(
self,
*,
limit: int = 1,
worker_id: Optional[str] = None,
) -> Sequence[AttemptedRollout]:
with self._lock:
return await self.store.start_attempt(rollout_id)
return await self.store.dequeue_many_rollouts(limit=limit, worker_id=worker_id)
async def start_attempt(self, rollout_id: str, worker_id: Optional[str] = None) -> AttemptedRollout:
with self._lock:
return await self.store.start_attempt(rollout_id, worker_id)
async def query_rollouts(
self,
+32 -65
View File
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
import time
from typing import Awaitable, Callable, List, cast
from typing import Awaitable, Callable, Dict, List, Tuple
from agentlightning.types import Attempt, AttemptedRollout, AttemptStatus, Rollout, RolloutConfig, RolloutStatus
@@ -57,66 +57,54 @@ LATENCY_BUCKETS = [
]
async def propagate_status(
update_rollout_status: UpdateRolloutStatus, # this should be unlocked
async def rollout_status_from_attempt(
attempt: Attempt,
config: RolloutConfig,
) -> Rollout:
) -> RolloutStatus:
"""
Propagate the status of an attempt to the rollout.
The rollout should be made sure in a state to be outdated.
Requeue the rollout if it should be retried.
This operation is completely unlocked. The caller is responsible for locking the store.
Returns:
The status of the rollout from the perspective of the attempt.
"""
# Propagate the status directly to the rollout
if attempt.status == "preparing" or attempt.status == "running" or attempt.status == "succeeded":
return await update_rollout_status(
attempt.rollout_id,
attempt.status,
)
return attempt.status
if attempt.status == "failed" or attempt.status == "timeout" or attempt.status == "unresponsive":
# Check if this status should trigger a retry
if attempt.status in config.retry_condition:
# If we haven't exceeded max attempts, retry
if attempt.sequence_id < config.max_attempts:
return await update_rollout_status(
attempt.rollout_id,
"requeuing",
)
return "requeuing"
# If we can't retry or shouldn't retry, mark as failed
return await update_rollout_status(
attempt.rollout_id,
"failed",
)
return "failed"
raise ValueError(f"Invalid attempt status: {attempt.status}")
async def healthcheck(
async def scan_unhealthy_rollouts(
rollouts: List[AttemptedRollout],
update_rollout_status: UpdateRolloutStatus,
update_attempt_status: UpdateAttemptStatus,
) -> None:
) -> Dict[Tuple[str, str], AttemptStatus]:
"""
Perform health check on all running rollouts in the store.
This method should be called periodically to:
1. Update rollout status to failed to succeeded when the attempt is done
2. Check for unresponsive attempts (no heartbeat or spans for a while)
3. Check for timed-out rollouts (running too long since start_time)
4. Update attempt/rollout status accordingly
1. Check for unresponsive attempts (no heartbeat or spans for a while)
2. Check for timed-out rollouts (running too long since start_time)
This operation is completely unlocked. The caller is responsible for locking the store.
Args:
store: The LightningStore instance to check rollouts from
rollouts: The list of running rollouts to check.
Returns:
A dictionary of updates to the rollouts.
"""
current_time = time.time()
updates: Dict[Tuple[str, str], AttemptStatus] = {}
for rollout in rollouts:
config = rollout.config # policy for retry and timeout
@@ -124,52 +112,31 @@ async def healthcheck(
# Get the latest attempt for this rollout
latest_attempt = rollout.attempt
if not latest_attempt:
continue
# Check if the attempt has already failed or succeeded
if latest_attempt.status == "failed" or latest_attempt.status == "succeeded":
await propagate_status(update_rollout_status, latest_attempt, config)
# This should not happen
continue
# Check for timeout condition (based on attempt start_time, instead of rollout start_time)
if config.timeout_seconds is not None and current_time - latest_attempt.start_time > config.timeout_seconds:
await update_attempt_status(
latest_attempt.rollout_id,
latest_attempt.attempt_id,
"timeout",
)
updates[(latest_attempt.rollout_id, latest_attempt.attempt_id)] = "timeout"
continue
# Check for unresponsive condition (based on last heartbeat)
if latest_attempt.last_heartbeat_time:
if latest_attempt.status == "preparing":
# If still preparing, mark it as running
latest_attempt = await update_attempt_status(
latest_attempt.rollout_id,
latest_attempt.attempt_id,
"running",
)
# (1) Haven't received heartbeat for a while
if (
latest_attempt.last_heartbeat_time
and config.unresponsive_seconds is not None
and current_time - latest_attempt.last_heartbeat_time > config.unresponsive_seconds
):
updates[(latest_attempt.rollout_id, latest_attempt.attempt_id)] = "unresponsive"
continue
# Haven't received heartbeat for a while
if (
config.unresponsive_seconds is not None
and current_time - cast(float, latest_attempt.last_heartbeat_time) > config.unresponsive_seconds
):
await update_attempt_status(
latest_attempt.rollout_id,
latest_attempt.attempt_id,
"unresponsive",
)
continue
# Check if there's no last heartbeat (no spans) at all
# (2) Check if there's no last heartbeat (no spans) at all
if (
latest_attempt.last_heartbeat_time is None
and config.unresponsive_seconds is not None
and current_time - latest_attempt.start_time > config.unresponsive_seconds
):
await update_attempt_status(
latest_attempt.rollout_id,
latest_attempt.attempt_id,
"unresponsive",
)
updates[(latest_attempt.rollout_id, latest_attempt.attempt_id)] = "unresponsive"
continue
return updates
+2 -1
View File
@@ -3,5 +3,6 @@
from .agentops import AgentOpsTracer
from .base import Tracer
from .otel import OtelTracer
from .weave import WeaveTracer
__all__ = ["AgentOpsTracer", "Tracer", "OtelTracer"]
__all__ = ["AgentOpsTracer", "Tracer", "OtelTracer", "WeaveTracer"]
-396
View File
@@ -1,396 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import multiprocessing
import queue
import uuid
from contextlib import asynccontextmanager, contextmanager
from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, Iterator, List, Optional, Tuple
from urllib.parse import urlparse
from httpdbg.hooks.all import httprecord
from httpdbg.records import HTTPRecords
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.trace import SpanKind, Status, StatusCode
from opentelemetry.trace.span import (
SpanContext,
TraceFlags,
TraceState,
)
from agentlightning.store import LightningStore
from .base import Tracer
logger = logging.getLogger(__name__)
class HttpTracer(Tracer):
"""
A tracer implementation that captures HTTP requests using httpdbg.
This tracer hooks into the Python HTTP libraries and captures all
HTTP requests and responses made during the traced code execution.
The captured requests are converted to OpenTelemetry spans for
compatibility with the rest of the tracing ecosystem.
Caution: The current implementation of HttpTracer is very fragile,
and we do not recommend using it in production.
It is primarily for demonstration and testing purposes.
Deprecated: This tracer is deprecated and will be removed in a future version.
Please use LLMProxy as an alternative.
Attributes:
include_headers: Whether to include HTTP headers in the spans.
Headers may contain sensitive information. Use with caution.
include_body: Whether to include HTTP request and response bodies in the spans.
Bodies may be large and contain sensitive information. Use with caution.
include_agentlightning_requests: Whether to include requests initiated by AgentLightning itself.
subprocess_mode: Whether to run trace_run and trace_run_async in subprocesses for isolation.
subprocess_timeout: Timeout for subprocess execution in seconds.
"""
AGENTLIGHTNING_HEADERS = {"x-agentlightning-client"}
def __init__(
self,
include_headers: bool = False,
include_body: bool = False,
include_agentlightning_requests: bool = False,
subprocess_mode: bool = True,
subprocess_timeout: float = 3600.0,
):
super().__init__()
self._last_records: Optional[HTTPRecords] = None
self.include_headers = include_headers
self.include_body = include_body
self.include_agentlightning_requests = include_agentlightning_requests
self.subprocess_mode = subprocess_mode
self.subprocess_timeout = subprocess_timeout
def init_worker(self, worker_id: int, store: Optional[LightningStore] = None) -> None:
"""
Initialize the tracer in a worker process.
Args:
worker_id: The ID of the worker process.
store: The store to add the spans to.
"""
super().init_worker(worker_id, store)
logger.info(f"[Worker {worker_id}] HttpTracer initialized.")
@asynccontextmanager
async def trace_context(self, name: Optional[str] = None, **kwargs: Any) -> AsyncGenerator[HTTPRecords, None]:
"""
Starts a new HTTP tracing context. This should be used as a context manager.
Args:
name: Optional name for the tracing context.
"""
with self._trace_context_sync(name=name, **kwargs) as records:
yield records
@contextmanager
def _trace_context_sync(self, name: Optional[str] = None, **kwargs: Any) -> Iterator[HTTPRecords]:
"""
Starts a new HTTP tracing context. This should be used as a context manager.
Args:
name: Optional name for the tracing context.
Yields:
The HTTPRecords instance containing traced HTTP activities.
"""
records = HTTPRecords()
with httprecord(records):
self._last_records = records
yield records
def get_last_trace(self) -> List[ReadableSpan]:
"""
Retrieves the raw list of captured spans from the most recent trace.
Returns:
A list of OpenTelemetry `ReadableSpan` objects converted from HTTP records.
"""
if self._last_records is None:
return []
return self._convert_to_spans(self._last_records)
def _convert_to_spans(self, records: HTTPRecords) -> List[ReadableSpan]:
"""
Convert HTTPRecords to OpenTelemetry spans.
Args:
records: The HTTPRecords instance containing HTTP traces.
Returns:
A list of ReadableSpan objects representing the HTTP activities.
"""
spans: List[ReadableSpan] = []
# Create a trace ID that will be shared by all spans in this trace
trace_id = int(uuid.uuid4().hex[:16], 16)
for record in records.requests.values():
# Skip AgentLightning requests if include_agentlightning_requests is False
should_skip = False
if not self.include_agentlightning_requests and record.request and record.request.headers:
for header in record.request.headers:
if header.name.lower() in self.AGENTLIGHTNING_HEADERS and header.value.lower() == "true":
should_skip = True
break
if should_skip:
continue
# Create a span ID for this specific HTTP request
span_id = int(uuid.uuid4().hex[:8], 16)
# Create a span context
span_context = SpanContext(
trace_id=trace_id,
span_id=span_id,
is_remote=False,
trace_flags=TraceFlags(TraceFlags.SAMPLED),
trace_state=TraceState(),
)
# Extract important information from the HTTP record
method = record.method
url = record.url
parsed_url = urlparse(url)
status_code = record.status_code
# Create attributes dictionary
attributes: Dict[str, Any] = {
"http.method": method,
"http.url": url,
"http.target": parsed_url.path,
"http.host": parsed_url.netloc,
}
if status_code is not None and status_code > 0: # type: ignore
attributes["http.status_code"] = status_code
# Calculate duration - from begin time to last update
duration = None
if hasattr(record, "last_update") and record.last_update and record.tbegin:
duration = (record.last_update - record.tbegin).total_seconds()
attributes["http.duration_ms"] = duration * 1000 # Convert to ms
# Optionally include headers
if self.include_headers and record.request and record.request.headers:
for header in record.request.headers:
header_name = header.name.lower()
attributes[f"http.request.header.{header_name}"] = header.value
if self.include_headers and record.response and record.response.headers:
for header in record.response.headers:
header_name = header.name.lower()
attributes[f"http.response.header.{header_name}"] = header.value
# Optionally include body - preserve complete content for analysis
if self.include_body and record.request:
body_content = record.request.content
if body_content:
# Store raw body content for later parsing/analysis
attributes["http.request.body"] = body_content
if self.include_body and record.response:
body_content = record.response.content
if body_content:
# Store raw body content for later parsing/analysis
attributes["http.response.body"] = body_content
# Determine span status
span_status = StatusCode.OK
if status_code and status_code >= 400 or record.exception:
span_status = StatusCode.ERROR
# Create start and end timestamps in nanoseconds
# If we have duration, use it, otherwise default to current time - 1ms
start_time_ns = int(record.tbegin.timestamp() * 1e9)
if duration:
end_time_ns = int((record.tbegin.timestamp() + duration) * 1e9)
else:
end_time_ns = int(record.last_update.timestamp() * 1e9)
span = ReadableSpan(
name=f"HTTP {method} {url}",
context=span_context,
parent=None,
kind=SpanKind.CLIENT,
status=Status(span_status),
start_time=start_time_ns,
end_time=end_time_ns,
attributes=attributes,
events=[],
links=[],
resource=None,
)
spans.append(span)
return spans
def trace_run(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
"""
A convenience wrapper to trace the execution of a single synchronous function.
If subprocess_mode is enabled, the function will be executed in an isolated subprocess
to prevent HTTP hooks from affecting the parent process.
Args:
func: The synchronous function to execute and trace.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
The return value of the function.
"""
if self.subprocess_mode:
return self._trace_run_subprocess(func, args, kwargs)
else:
return super().trace_run(func, *args, **kwargs)
async def trace_run_async(self, func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> Any:
"""
A convenience wrapper to trace the execution of a single asynchronous function.
If subprocess_mode is enabled, the function will be executed in an isolated subprocess
to prevent HTTP hooks from affecting the parent process.
Args:
func: The asynchronous function to execute and trace.
*args: Positional arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
The return value of the function.
"""
if self.subprocess_mode:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, self._trace_run_subprocess, func, args, kwargs, True # True for async
)
else:
return await super().trace_run_async(func, *args, **kwargs)
def _trace_run_subprocess(
self,
func: Callable[..., Any],
args: Optional[Tuple[Any, ...]] = None,
kwargs: Optional[Dict[str, Any]] = None,
is_async: bool = False,
) -> Any:
"""
Execute a function in a subprocess with HTTP tracing.
Args:
func: The function to execute.
args: Positional arguments to pass to the function.
kwargs: Keyword arguments to pass to the function.
is_async: Whether the function is asynchronous.
Returns:
The return value of the function.
"""
if args is None:
args = ()
if kwargs is None:
kwargs = {}
# Create a queue to receive results from the subprocess
result_queue = multiprocessing.Queue() # type: ignore
# Create and start the subprocess
process = multiprocessing.Process(
target=self._subprocess_worker, args=(func, args, kwargs, result_queue, is_async) # type: ignore
)
process.start()
try:
# Wait for the process to complete and get the result
process.join(timeout=self.subprocess_timeout)
result = result_queue.get_nowait() # type: ignore
if result["success"]:
# Store the captured records for get_last_trace()
self._last_records = result["records"]
return result["return_value"] # type: ignore
else:
if "records" in result:
self._last_records = result["records"]
# Re-raise the exception that occurred in the subprocess
raise result["exception"]
except multiprocessing.TimeoutError:
process.terminate()
process.join()
raise TimeoutError(f"Subprocess execution timed out after {self.subprocess_timeout} seconds.")
except queue.Empty:
logger.error("Traced result is empty. This may indicate a timeout or an issue with the subprocess.")
finally:
if process.is_alive():
process.terminate()
process.join()
def _subprocess_worker(
self,
func: Callable[..., Any],
args: Tuple[Any, ...],
kwargs: Dict[str, Any],
result_queue: multiprocessing.Queue, # type: ignore
is_async: bool,
) -> None:
"""
Worker function that runs in the subprocess to execute the traced function.
Args:
func: The function to execute.
args: Positional arguments.
kwargs: Keyword arguments.
result_queue: Queue to send results back to parent process.
is_async: Whether the function is asynchronous.
"""
# Create a new tracer instance in the subprocess (without subprocess mode to avoid recursion)
subprocess_tracer = HttpTracer(
include_headers=self.include_headers,
include_body=self.include_body,
include_agentlightning_requests=self.include_agentlightning_requests,
subprocess_mode=False, # Disable subprocess mode in the worker
)
try:
if is_async:
# Run async function in new event loop
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return_value = loop.run_until_complete(subprocess_tracer.trace_run_async(func, *args, **kwargs))
finally:
loop.close()
else:
# Run sync function
return_value = subprocess_tracer.trace_run(func, *args, **kwargs)
# Get the captured records
records = subprocess_tracer._last_records
# Send success result back to parent
result_queue.put({"success": True, "return_value": return_value, "records": records}) # type: ignore
except Exception as e:
# Log the exception
logger.exception(f"Error in subprocess worker in http tracer: {e}")
# Get the captured records even when there's an exception
records = subprocess_tracer._last_records
# Send error result back to parent
result_queue.put({"success": False, "exception": e, "records": records}) # type: ignore
+307
View File
@@ -0,0 +1,307 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Tuple, Union
from agentlightning.instrumentation import instrument_weave, uninstrument_weave
from agentlightning.store.base import LightningStore
from agentlightning.types.tracer import OtelResource, Span, SpanContext, TraceStatus
from .base import Tracer
if TYPE_CHECKING:
from weave.trace.call import Call # type: ignore
JSONPrimitive = Union[str, int, float, bool, None]
logger = logging.getLogger(__name__)
class WeaveTracer(Tracer):
"""
Tracer implementation using Weave for telemetry and trace logging.
This replaces AgentOpsTracer with a Weave-based manual trace context. It tracks:
- Function/method calls
- Input/Output data
- Exceptions
and logs them to Weave Cloud (W&B backend) or optionally bypasses the network for testing.
Attributes:
project_name: Name of the Weave project. Used to initialize the Weave client.
_store: Optional LightningStore instance for storing collected spans.
instrument_managed: Whether to patch the Weave/W&B integration to bypass actual network calls for testing.
"""
def __init__(
self, *, project_name: str | None = None, wandb_api_key: str | None = None, instrument_managed: bool = True
):
"""
Initialize a WeaveTracer instance.
Args:
project_name: Optional project name for Weave; defaults to the current module name.
wandb_api_key: Optional W&B API key; sets environment variable if provided.
instrument_managed: Whether to patch the Weave/W&B integration to bypass actual network calls for testing.
"""
super().__init__()
self.project_name = project_name or __name__
self.sequence_id = 0
self._store: Optional[LightningStore] = None
self.instrument_managed = instrument_managed
if wandb_api_key:
os.environ["WANDB_API_KEY"] = wandb_api_key
def instrument(self, worker_id: int):
instrument_weave()
def uninstrument(self, worker_id: int):
uninstrument_weave()
def init_worker(self, worker_id: int, store: Optional[LightningStore] = None):
"""
Initialize the tracer for a worker thread/process.
Args:
worker_id: Identifier of the worker.
store: Optional LightningStore for storing spans.
"""
super().init_worker(worker_id, store)
logger.info(f"[Worker {worker_id}] Setting up Weave tracer...")
self._store = store
try:
import weave
except ImportError:
raise RuntimeError("Weave is not installed. Install it to use WeaveTracer.")
# Optionally patch network calls to bypass real Weave/W&B endpoints
if self.instrument_managed:
self.instrument(worker_id)
# Initialize the Weave client if not already initialized
if weave.get_client() is None: # type: ignore
try:
weave.init(project_name=self.project_name) # type: ignore
logger.info(f"[Worker {worker_id}] Weave client initialized.")
except Exception as e:
raise RuntimeError(f"Failed to initialize Weave for project '{self.project_name}': {e}")
def teardown_worker(self, worker_id: int):
"""
Clean up tracer resources for the worker.
Args:
worker_id: Identifier of the worker.
"""
super().teardown_worker(worker_id)
if self.instrument_managed:
self.uninstrument(worker_id)
logger.info(f"[Worker {worker_id}] Instrumentation removed.")
@asynccontextmanager
async def trace_context(
self,
name: Optional[str] = None,
*,
store: Optional[LightningStore] = None,
rollout_id: Optional[str] = None,
attempt_id: Optional[str] = None,
) -> AsyncIterator[Any]:
"""
Synchronous implementation of the tracing context.
Args:
name: Optional operation name.
store: Optional LightningStore instance.
rollout_id: Optional rollout ID.
attempt_id: Optional attempt ID.
Raises:
ValueError: If store, rollout_id, and attempt_id are inconsistently provided.
RuntimeError: If Weave is not installed or client is uninitialized.
"""
arg_op = name or self.project_name
arg_inputs: dict[str, str] | None = {"rollout_id": rollout_id or "", "attempt_id": attempt_id or ""}
if store is not None and rollout_id is not None and attempt_id is not None:
self._rollout_id = rollout_id
self._attempt_id = attempt_id
self._store = store
else:
raise ValueError("store, rollout_id, and attempt_id must be either all provided")
try:
import datetime
import weave
except ImportError:
raise RuntimeError("Weave is not installed. Install it to use WeaveTracer.")
weave_client = weave.get_client() # type: ignore
if not weave_client:
raise RuntimeError("Weave client is not initialized. Call init_worker() first.")
# Create a new trace call object in Weave
trace_call = weave_client.create_call(op=arg_op, inputs=arg_inputs) # type: ignore
trace_call.started_at = datetime.datetime.now(tz=datetime.timezone.utc)
try:
yield trace_call
except Exception as e:
# Finish trace and log any exception
weave_client.finish_call(trace_call, exception=e) # type: ignore
logger.error(f"Trace failed for rollout_id={rollout_id}, attempt_id={attempt_id}, error={e}")
finally:
# Finish trace even if no exception
weave_client.finish_call(trace_call) # type: ignore
await self._on_finish_handler(trace_call) # type: ignore
async def _on_finish_handler(self, call: "Call", *args: Any, **kwargs: Any) -> None: # type: ignore
"""
Handler called when a Weave Call finishes.
Converts the call (including nested children) into spans and stores them in LightningStore.
"""
spans, self.sequence_id = self.convert_call_to_spans(call, self._rollout_id, self._attempt_id, self.sequence_id) # type: ignore
if self._store and self._rollout_id and self._attempt_id:
try:
await self._store.add_many_spans(spans)
except Exception as e:
logger.exception(f"Error adding span to store: {e}")
def convert_call_to_spans(
self,
call: "Call", # type: ignore
rollout_id: Optional[str] = None,
attempt_id: Optional[str] = None,
seq_start: int = 0,
) -> tuple[List[Span], int]:
"""
Recursively convert a Weave Call (with nested children) into a flat list of Agent Lightning Spans.
Args:
call: The Weave Call object.
rollout_id: Optional rollout ID to attach to spans.
attempt_id: Optional attempt ID to attach to spans.
seq_start: Sequence number to start from.
Returns:
Tuple of (list_of_spans, next_sequence_id).
"""
spans: List[Span] = []
sequence_id = seq_start
rollout_id = rollout_id or "" # type: ignore
attempt_id = attempt_id or "" # type: ignore
start_dt = getattr(call, "started_at", None) # type: ignore
start_ts: Optional[float] = start_dt.timestamp() if start_dt else None
end_dt = getattr(call, "ended_at", None) # type: ignore
end_ts: Optional[float] = end_dt.timestamp() if end_dt else None
trace_id = str(getattr(call, "trace_id", None)) # type: ignore
span_id = str(getattr(call, "id", None)) # type: ignore
parent_id = str(getattr(call, "parent_id", None)) if getattr(call, "parent_id", None) else None # type: ignore
exception = getattr(call, "exception", None) # type: ignore
status_code = "ERROR" if exception else "OK"
def sanitize(
inputs: Dict[str, Any],
output: Dict[str, Any],
) -> Dict[str, str | JSONPrimitive]:
stack: List[Tuple[Any, str]] = [
(inputs or {}, "input"),
(output or {}, "output"),
]
attributes: Dict[str, str | JSONPrimitive] = {}
while stack:
value, key = stack.pop()
if isinstance(value, dict):
for k, v in value.items(): # type: ignore
stack.append((v, f"{key}.{k}")) # type: ignore
elif isinstance(value, (list, tuple)):
for i, v in enumerate(value): # type: ignore
stack.append((v, f"{key}.{i}")) # type: ignore
else:
if value is None:
attributes[key] = "None"
elif isinstance(value, (str, int, float, bool)):
attributes[key] = value
else:
try:
attributes[key] = str(value)
except Exception:
attributes[key] = "None"
return attributes
inputs = getattr(call, "inputs", {}) # type: ignore
output = getattr(call, "output", {}) # type: ignore
attributes = sanitize(inputs, output)
context = SpanContext(
trace_id=trace_id,
span_id=span_id,
is_remote=False,
trace_state={},
)
parent_context = (
SpanContext(
trace_id=trace_id,
span_id=parent_id,
is_remote=False,
trace_state={},
)
if parent_id
else None
)
# Build the Span object
span = Span(
rollout_id=rollout_id or "",
attempt_id=attempt_id or "",
sequence_id=sequence_id,
trace_id=trace_id,
span_id=span_id,
parent_id=parent_id,
name=getattr(call, "func_name", "unknown"), # type: ignore
status=TraceStatus(status_code=status_code),
attributes=attributes, # type: ignore
events=[], # Weave calls do not generate events
links=[], # Weave calls do not generate links
start_time=start_ts,
end_time=end_ts,
context=context,
parent=parent_context,
resource=OtelResource(attributes={}, schema_url=""),
)
spans.append(span)
sequence_id += 1
children: List["Call"] = getattr(call, "_children", []) # type: ignore
# Recursively process child calls
for child in children: # type: ignore
child_spans, sequence_id = self.convert_call_to_spans( # type: ignore
child, # type: ignore
rollout_id=rollout_id,
attempt_id=attempt_id,
seq_start=sequence_id,
)
spans.extend(child_spans)
return spans, sequence_id
+21 -7
View File
@@ -152,6 +152,13 @@ class Trainer(TrainerLegacy):
# super().__init__() will call TrainerLegacy's initialization, which is not intended.
self.worker_id: Optional[int] = None
if dev:
warnings.warn(
"Trainer(dev=True) is deprecated and will be removed in future versions. "
"Please use Trainer.dev(...) instead.",
DeprecationWarning,
stacklevel=2,
)
self._dev = dev
self.daemon = daemon
self._client: AgentLightningClient | None = None # Will be initialized in fit or fit_v0
@@ -213,10 +220,6 @@ class Trainer(TrainerLegacy):
# We might be able to support a list of resources in future.
self.initial_resources = initial_resources
# The active store for the current execution context
self.store = self._make_store(store)
self.runner = self._make_runner(runner)
self.port = port
self.strategy = self._make_strategy(
@@ -224,6 +227,11 @@ class Trainer(TrainerLegacy):
n_runners=self.n_runners,
port=port,
)
# The active store for the current execution context
self.store = self._make_store(store, self.strategy)
self.runner = self._make_runner(runner)
if hasattr(self.strategy, "n_runners"):
strategy_runners = getattr(self.strategy, "n_runners")
if isinstance(strategy_runners, int) and strategy_runners > 0:
@@ -282,13 +290,19 @@ class Trainer(TrainerLegacy):
type_error_fmt="Adapter factory returned {type_name}, which is not a TraceAdapter subclass.",
)
def _make_store(self, store: ComponentSpec[LightningStore]) -> LightningStore:
"""Resolve the store implementation backing rollouts, attempts, spans, and resources."""
def _make_store(self, store: ComponentSpec[LightningStore], strategy: ExecutionStrategy) -> LightningStore:
"""Resolve the store implementation backing rollouts, attempts, spans, and resources.
By default, it's always a in-memory store. If using a client/server execution strategy,
the in-memory store will be initialized in a thread-safe manner.
"""
is_client_server = isinstance(strategy, ClientServerExecutionStrategy)
default_store_factory = lambda: InMemoryLightningStore(thread_safe=is_client_server)
return build_component(
store,
expected_type=LightningStore,
spec_name="store",
default_factory=InMemoryLightningStore,
default_factory=default_store_factory,
invalid_spec_error_fmt="Invalid store type: {actual_type}. Expected LightningStore, str, dict, or None.",
type_error_fmt="Store factory returned {type_name}, which is not a LightningStore subclass.",
)
+19
View File
@@ -53,6 +53,7 @@ __all__ = [
"Rollout",
"Attempt",
"AttemptedRollout",
"EnqueueRolloutRequest",
"Hook",
"Worker",
"WorkerStatus",
@@ -211,6 +212,24 @@ class AttemptedRollout(Rollout):
return self
class EnqueueRolloutRequest(BaseModel):
"""Payload describing a rollout to be queued via [`enqueue_rollout`][agentlightning.LightningStore.enqueue_rollout].
A subset of fields from [`Rollout`][agentlightning.Rollout] used for queuing new rollouts.
"""
input: TaskInput
"""Task input used to generate the rollout."""
mode: Optional[RolloutMode] = None
"""Execution mode such as `"train"`, `"val"` or `"test"`. See [`RolloutMode`][agentlightning.RolloutMode]."""
resources_id: Optional[str] = None
"""Identifier of the resources required to execute the rollout."""
config: Optional[RolloutConfig] = None
"""Retry and timeout configuration associated with the rollout."""
metadata: Optional[Dict[str, Any]] = None
"""Additional metadata attached to the rollout."""
WorkerStatus = Literal["idle", "busy", "unknown"]
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -940,9 +940,9 @@ class PythonServerLauncher:
), # Allow half the timeout for graceful shutdown
}
if "PROMETHEUS_MULTIPROC_DIR" in os.environ:
from prometheus_client import multiprocess
from agentlightning.utils.metrics import shutdown_metrics
options["child_exit"] = lambda server, worker: multiprocess.mark_process_dead(worker.pid) # type: ignore
options["child_exit"] = shutdown_metrics # type: ignore
self._gunicorn_app = GunicornApp(self.app, options)
+150 -26
View File
@@ -9,7 +9,7 @@ import time
import uuid
from collections import defaultdict
from collections.abc import Mapping
from typing import Any, Dict, List, Literal, Optional, Tuple
from typing import Any, Dict, List, Literal, Optional, Tuple, cast
import numpy as np
import requests
@@ -22,7 +22,7 @@ from agentlightning import LLM, AgentLightningServer, NamedResources, RolloutLeg
from agentlightning.adapter.triplet import TracerTraceToTriplet, TraceToTripletBase
from agentlightning.llm_proxy import LLMProxy, ModelConfig
from agentlightning.store.base import LightningStore
from agentlightning.types import Rollout, RolloutConfig, Task
from agentlightning.types import EnqueueRolloutRequest, Rollout, RolloutConfig, Task
__all__ = [
"AgentModeDaemon",
@@ -144,6 +144,8 @@ class AgentModeDaemon:
llm_proxy: LLMProxy | None = None,
store: LightningStore | None = None,
adapter: TraceToTripletBase | None = None,
processor: Any = None,
image_base_dir: Optional[str] = None,
):
self.mode = mode
self.llm_timeout_seconds = llm_timeout_seconds
@@ -183,7 +185,12 @@ class AgentModeDaemon:
self.mini_batch_size = mini_batch_size
self.pad_token_id = pad_token_id
self.tokenizer = tokenizer
self.processor = processor
self.reward_fillna_value = reward_fillna_value
self.image_base_dir = image_base_dir
# Check if model requires multimodal position_ids (e.g., Qwen2-VL)
self._use_mrope = self._is_mrope_model()
# Internal State
self.backend_llm_server_addresses: List[str] = []
@@ -202,6 +209,75 @@ class AgentModeDaemon:
loop.run_forever()
loop.close()
# Multimodal utilities for M-RoPE position embeddings
def _is_mrope_model(self) -> bool:
"""Check if processor requires M-RoPE position embeddings."""
if self.processor is None or not hasattr(self.processor, "image_processor"):
return False
name = self.processor.image_processor.__class__.__name__
return "Qwen2VLImageProcessor" in name or "Qwen3VLImageProcessor" in name
def _resolve_image_path(self, path: str) -> str:
"""Resolve relative image path with base directory."""
import os
if os.path.isabs(path):
return path
if self.image_base_dir is None:
raise ValueError(f"Relative path '{path}' requires 'image_base_dir' to be set.")
return os.path.join(self.image_base_dir, path)
def _get_image_grid_thw(self, image_urls: List[str]) -> Optional[torch.Tensor]:
"""Compute image_grid_thw from image URLs for M-RoPE computation.
Args:
image_urls: List of image URLs extracted from triplet prompt payload.
URLs can be http(s):// URLs or file:// URIs, or data: URIs.
"""
from PIL import Image
from verl.utils.dataset.vision_utils import process_image # pyright: ignore[reportUnknownVariableType]
if self.processor is None or not image_urls:
return None
def to_image_uri(url: str) -> str:
# Already a proper URI (http, https, file, data)
if url.startswith(("http://", "https://", "file://", "data:")):
return url
# Treat as a file path that needs resolution
resolved = self._resolve_image_path(url)
return f"file://{resolved}"
images: List[Image.Image] = [process_image({"image": to_image_uri(url)}) for url in image_urls]
model_inputs = self.processor(text=["dummy"], images=images, return_tensors="pt")
return model_inputs.get("image_grid_thw")
def _compute_mrope_position_ids(
self,
input_ids: torch.Tensor,
attention_mask: torch.Tensor,
image_grid_thw: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Compute 4D position_ids for M-RoPE models."""
from typing import Callable
get_rope_index: Callable[..., torch.Tensor]
if "Qwen3VL" in self.processor.__class__.__name__:
from verl.models.transformers.qwen3_vl import get_rope_index # pyright: ignore[reportUnknownVariableType]
else:
from verl.models.transformers.qwen2_vl import get_rope_index # pyright: ignore[reportUnknownVariableType]
vision_pos = get_rope_index(
self.processor, input_ids=input_ids, image_grid_thw=image_grid_thw, attention_mask=attention_mask
)
valid_mask = attention_mask.bool()
text_pos = torch.zeros((1, len(input_ids)), dtype=torch.long, device=input_ids.device)
text_pos[0, valid_mask] = torch.arange(valid_mask.sum().item(), device=input_ids.device)
return torch.cat([text_pos, vision_pos], dim=0)
def _start_proxy_server_v0(self):
"""
Initializes and runs a Flask-based proxy server in a separate thread.
@@ -377,42 +453,57 @@ class AgentModeDaemon:
num_samples = len(data[keys[0]])
rollouts_per_sample = self.train_rollout_n if is_train else 1
enqueue_rollout_requests: List[EnqueueRolloutRequest] = []
data_id_to_original_sample: Dict[str, Dict[str, Any]] = {}
for i in range(num_samples):
data_id = str(uuid.uuid4())
original_sample = {key: data[key][i] for key in keys}
original_sample["data_id"] = data_id
data_id_to_original_sample[data_id] = original_sample
# For training, each sample is rolled out multiple times
# Data ID is different from Rollout ID, as one data can have multiple rollouts.
for _ in range(rollouts_per_sample):
task_metadata = {"data_id": data_id, "is_train": is_train}
# Data ID is different from Rollout ID, as one data can have multiple rollouts.
if self.mode == "v0":
# Queue immediately
rollout_id = await self.server.queue_task(
sample=_to_native(original_sample),
mode="train" if is_train else "val",
resources_id=resources_id,
metadata=task_metadata,
)
else:
rollout = await self.store.enqueue_rollout(
input=_to_native(original_sample),
mode="train" if is_train else "val",
resources_id=resources_id,
metadata=task_metadata,
)
await self.store.update_rollout(
rollout_id=rollout.rollout_id,
config=RolloutConfig(
unresponsive_seconds=self.llm_timeout_seconds,
timeout_seconds=self.llm_timeout_seconds,
),
)
rollout_id = rollout.rollout_id
# Store original sample data to reconstruct batch information later
self._task_id_to_original_sample[rollout_id] = original_sample
self._total_tasks_queued += 1
# Store original sample data to reconstruct batch information later
self._task_id_to_original_sample[rollout_id] = original_sample
self._total_tasks_queued += 1
else:
# Collect tasks to enqueue in batch and queue them later
enqueue_rollout_requests.append(
EnqueueRolloutRequest(
input=_to_native(original_sample),
mode="train" if is_train else "val",
resources_id=resources_id,
config=RolloutConfig(
unresponsive_seconds=self.llm_timeout_seconds,
timeout_seconds=self.llm_timeout_seconds,
),
metadata=task_metadata,
)
)
if self.mode == "v1":
# Enqueue all the tasks in a single batch
rollouts = await self.store.enqueue_many_rollouts(enqueue_rollout_requests)
self._task_id_to_original_sample.update(
{
# Recover the original data and store it for later use.
rollout.rollout_id: data_id_to_original_sample[cast(Dict[str, Any], rollout.metadata)["data_id"]]
for rollout in rollouts
}
)
self._total_tasks_queued += len(rollouts)
def set_up_data_and_server(self, data: Dict[str, Any], server_addresses: List[str], is_train: bool = True):
"""Synchronous wrapper for setting up data and server resources."""
@@ -657,10 +748,14 @@ class AgentModeDaemon:
continue
# The client should report triplets that contain prompt_ids and response_ids.
# Example triplet.prompt: {"token_ids": [...]}
# Example triplet.prompt: {"token_ids": [...], "image_urls": [...]}
# Example triplet.response: {"token_ids": [...]}
trace_list = [
{"prompt_ids": t.prompt.get("token_ids", []), "response_ids": t.response.get("token_ids", [])}
{
"prompt_ids": t.prompt.get("token_ids", []),
"response_ids": t.response.get("token_ids", []),
"image_urls": t.prompt.get("image_urls", []),
}
for t in rollout.triplets
]
info = {
@@ -690,6 +785,7 @@ class AgentModeDaemon:
rollout_id_list: List[str] = []
turn_index_list: List[int] = []
is_drop_list: List[bool] = []
image_grid_thw_list: List[Optional[torch.Tensor]] = [] # For Qwen2-VL mrope
n_trunc_sample_because_of_response = 0
for rollout_id, sample_info in finished_id_to_sample_info.items():
@@ -726,6 +822,11 @@ class AgentModeDaemon:
rollout_id_list.append(rollout_id)
turn_index_list.append(turn_index)
# Compute image_grid_thw for this triplet using image_urls from prompt
if self._use_mrope:
image_urls = trace.get("image_urls", [])
image_grid_thw_list.append(self._get_image_grid_thw(image_urls))
n_transition = len(input_ids_list)
batch_input_ids = torch.LongTensor(input_ids_list).to(device)
input_attention_mask = torch.LongTensor(input_attention_mask_list).to(device)
@@ -735,15 +836,38 @@ class AgentModeDaemon:
# Concatenate prompts and responses to form the full sequence
batch_seq = torch.cat([batch_input_ids, batch_response_ids], dim=-1)
attention_mask = torch.cat([input_attention_mask, response_attention_mask], dim=-1)
position_ids = torch.clamp(torch.cumsum(attention_mask, dim=-1) - 1, min=0)
# Compute position_ids - use mrope for Qwen2-VL, standard 2D otherwise
if self._use_mrope:
# For Qwen2-VL: compute 4D position_ids (batch_size, 4, seq_length)
position_ids_list: list[torch.Tensor] = []
for i in range(n_transition):
pos_ids = self._compute_mrope_position_ids(
input_ids=batch_seq[i],
attention_mask=attention_mask[i],
image_grid_thw=image_grid_thw_list[i] if image_grid_thw_list else None,
) # (4, seq_length)
position_ids_list.append(pos_ids)
# Stack to (batch_size, 4, seq_length)
position_ids = torch.stack(position_ids_list, dim=0)
else:
# Standard 2D position_ids (batch_size, seq_length)
position_ids = torch.clamp(torch.cumsum(attention_mask, dim=-1) - 1, min=0)
is_drop_mask = torch.BoolTensor(is_drop_list).to(device)
scores = torch.tensor(reward_list, dtype=torch.bfloat16).to(device)
# Create token-level scores by placing the final reward at the last token position
token_level_scores = torch.zeros_like(attention_mask, dtype=scores.dtype)
# For mrope (3D position_ids), use the first dimension (text position_ids) for eos calculation
if self._use_mrope:
# position_ids is (batch_size, 4, seq_length), use first dim for text positions
text_position_ids = position_ids[:, 0, :] # (batch_size, seq_length)
eos_mask_idx = torch.argmax(text_position_ids * attention_mask, dim=-1) # (bsz,)
else:
eos_mask_idx = torch.argmax(position_ids * attention_mask, dim=-1) # (bsz,)
# At the eos_mask_idx position of each sample, fill in the corresponding scores.
# torch.arange(n_transition) generates [0,1,2,...,bsz-1] as indices for the batch dimension.
eos_mask_idx = torch.argmax(position_ids * attention_mask, dim=-1) # (bsz,)
token_level_scores[torch.arange(n_transition), eos_mask_idx] = scores
# Only take the last response_length part of the sequence to get the token-level scores for the model's response part.
token_level_scores = token_level_scores[:, -max_response_length:]
+49 -16
View File
@@ -1,13 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
# type: ignore
# pyright: reportUnknownVariableType=false
# pyright: reportUnknownMemberType=false
# pyright: reportUnknownArgumentType=false
from importlib.metadata import version
from typing import Any
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Type
import hydra
import ray
from packaging import version as packaging_version
from ray.actor import ActorClass
from verl.trainer.main_ppo import create_rl_sampler
from verl.trainer.ppo.reward import load_reward_manager
@@ -17,7 +20,10 @@ from agentlightning.store.base import LightningStore
from agentlightning.types import Dataset
from .dataset import AgentDataset, LoadedDataset
from .trainer import AgentLightningTrainer
if TYPE_CHECKING:
from .daemon import AgentModeDaemon
from .trainer import AgentLightningTrainer
__all__ = [
"main",
@@ -27,8 +33,20 @@ __all__ = [
@hydra.main(config_path="pkg://agentlightning/verl", config_name="config", version_base=None)
def main(config):
run_ppo(config, train_dataset=None, val_dataset=None, store=None, llm_proxy=None, adapter=None)
def main(config: Any):
from .daemon import AgentModeDaemon
from .trainer import AgentLightningTrainer
run_ppo(
config,
train_dataset=None,
val_dataset=None,
store=None,
llm_proxy=None,
adapter=None,
trainer_cls=AgentLightningTrainer,
daemon_cls=AgentModeDaemon,
)
def run_ppo(
@@ -38,6 +56,8 @@ def run_ppo(
store: LightningStore | None,
llm_proxy: LLMProxy | None,
adapter: TraceAdapter[Any] | None,
trainer_cls: Type[AgentLightningTrainer],
daemon_cls: Type[AgentModeDaemon],
) -> None:
if not ray.is_initialized():
# this is for local ray cluster
@@ -56,13 +76,15 @@ def run_ppo(
runner = TaskRunner.remote()
ray.get(
runner.run.remote(
runner.run.remote( # type: ignore
config=config,
train_dataset=train_dataset,
val_dataset=val_dataset,
store=store,
llm_proxy=llm_proxy,
adapter=adapter,
trainer_cls=trainer_cls,
daemon_cls=daemon_cls,
)
)
@@ -72,11 +94,13 @@ class TaskRunner:
def run(
self,
config: Any,
train_dataset: Dataset | None,
val_dataset: Dataset | None,
train_dataset: Dataset[Any] | None,
val_dataset: Dataset[Any] | None,
store: LightningStore | None,
llm_proxy: LLMProxy | None,
adapter: TraceAdapter | None,
adapter: TraceAdapter[Any] | None,
trainer_cls: Type[AgentLightningTrainer],
daemon_cls: Type[AgentModeDaemon],
):
# print initial config
from pprint import pprint
@@ -91,7 +115,7 @@ class TaskRunner:
local_path = copy_to_local(config.actor_rollout_ref.model.path)
# instantiate tokenizer
from verl.utils import hf_processor, hf_tokenizer
from verl.utils.tokenizer import hf_processor, hf_tokenizer
trust_remote_code = config.data.get("trust_remote_code", False)
tokenizer = hf_tokenizer(local_path, trust_remote_code=trust_remote_code)
@@ -112,7 +136,8 @@ class TaskRunner:
elif config.actor_rollout_ref.actor.strategy == "megatron":
assert config.actor_rollout_ref.actor.strategy == config.critic.strategy
from verl.single_controller.ray.megatron import NVMegatronRayWorkerGroup
# FIXME: This import is outdated
from verl.single_controller.ray.megatron import NVMegatronRayWorkerGroup # type: ignore
from verl.workers.megatron_workers import ActorRolloutRefWorker, CriticWorker
actor_rollout_cls = ActorRolloutRefWorker
@@ -121,9 +146,16 @@ class TaskRunner:
else:
raise NotImplementedError
from verl.trainer.ppo.ray_trainer import ResourcePoolManager, Role
from verl.trainer.ppo.ray_trainer import ResourcePoolManager
role_worker_mapping = {
try:
# verl >= 0.6.0
from verl.trainer.ppo.utils import Role
except ImportError:
# Fallback for verl <= 0.5.0
from verl.trainer.ppo.ray_trainer import Role # type: ignore
role_worker_mapping: dict[Role, ActorClass[Any]] = {
Role.ActorRollout: ray.remote(actor_rollout_cls),
Role.Critic: ray.remote(CriticWorker),
}
@@ -190,7 +222,7 @@ class TaskRunner:
val_dataset = LoadedDataset(val_dataset)
train_sampler = create_rl_sampler(config.data, train_dataset)
trainer = AgentLightningTrainer(
trainer = trainer_cls(
config=config,
tokenizer=tokenizer,
processor=processor,
@@ -206,6 +238,7 @@ class TaskRunner:
store=store,
llm_proxy=llm_proxy,
adapter=adapter,
daemon_cls=daemon_cls,
)
trainer.init_workers()
trainer.fit()
+43 -4
View File
@@ -8,7 +8,7 @@ import random
from contextlib import contextmanager
from copy import deepcopy
from pprint import pprint
from typing import Dict, Tuple
from typing import Dict, Tuple, Type
import numpy as np
import torch
@@ -174,12 +174,18 @@ class AgentLightningTrainer(RayPPOTrainer):
"""
def __init__(
self, store: LightningStore | None, llm_proxy: LLMProxy | None, adapter: TraceAdapter | None, **kwargs
self,
store: LightningStore | None,
llm_proxy: LLMProxy | None,
adapter: TraceAdapter | None,
daemon_cls: Type[AgentModeDaemon],
**kwargs,
):
super().__init__(**kwargs)
self.store = store
self.llm_proxy = llm_proxy
self.adapter = adapter
self.daemon_cls = daemon_cls
def _validate(self):
assert len(self.val_dataloader) == 1, "Please set val_batch_size to None for better throughput."
@@ -199,6 +205,37 @@ class AgentLightningTrainer(RayPPOTrainer):
self.async_rollout_manager.sleep()
return test_metrics
def _compute_reference_log_prob(self, batch: DataProto) -> DataProto:
"""Compute reference log probability using the correct worker based on LoRA configuration.
In verl 0.6.0+, when LoRA is detected (indicated by ref_in_actor=True),
the reference policy is computed by the actor rollout worker instead of a separate
ref policy worker. This method handles both scenarios by checking the ref_in_actor flag.
Note: verl sets ref_in_actor=True when it detects LoRA configuration (e.g., lora_rank > 0 or lora_adapter_path is set).
Args:
batch: The data batch to compute reference log probabilities for.
Returns:
DataProto with reference log probabilities added.
Raises:
RuntimeError: If the required worker is not available.
"""
if getattr(self, "ref_in_actor", False):
actor_worker = getattr(self, "actor_rollout_wg", None)
if actor_worker is None:
raise RuntimeError("actor_rollout_wg is required when ref_in_actor is True.")
return actor_worker.compute_ref_log_prob(batch)
ref_worker = getattr(self, "ref_policy_wg", None)
if ref_worker is None:
raise RuntimeError(
"Reference policy worker was not initialized. "
"Ensure `use_reference_policy` is enabled and the VERL config exposes the ref worker."
)
return ref_worker.compute_ref_log_prob(batch)
def _train_step(self, batch_dict: dict) -> dict:
# Isolate in a separate method to automatically recycle the variables before validation.
batch: DataProto = DataProto.from_single_dict(batch_dict)
@@ -276,7 +313,7 @@ class AgentLightningTrainer(RayPPOTrainer):
if self.use_reference_policy:
# compute reference log_prob
with _timer("ref", timing_raw):
ref_log_prob = self.ref_policy_wg.compute_ref_log_prob(batch)
ref_log_prob = self._compute_reference_log_prob(batch)
batch = batch.union(ref_log_prob)
# compute values
@@ -413,7 +450,7 @@ class AgentLightningTrainer(RayPPOTrainer):
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.agent_mode_daemon = self.daemon_cls(
self.config.agentlightning.port,
self.config.actor_rollout_ref.rollout.n,
train_information={
@@ -427,6 +464,8 @@ class AgentLightningTrainer(RayPPOTrainer):
store=self.store,
llm_proxy=self.llm_proxy,
adapter=self.adapter,
processor=self.processor, # For Qwen2-VL mrope position_ids
image_base_dir=getattr(self.config.data, "image_base_dir", None),
)
self.agent_mode_daemon.start()
+1
View File
@@ -0,0 +1 @@
# Put contrib-related gitignore files here.
+4
View File
@@ -0,0 +1,4 @@
# Put code owner definitions here.
# Recipes
recipes/search_r1 @SiyunZhao @JiahangXu
+21
View File
@@ -0,0 +1,21 @@
# Contrib Area
This tree hosts experimental integrations, third-party recipes, and curated recipes that are not ready for the main `agentlightning/`, `examples/`, or `docs/` trees. Treat it as an incubator: keep contributions self-contained, clearly owned, and reproducible so downstream users can vendor them without guesswork.
## When to add something here
- You are iterating on a runtime extension that would bloat the primary `agentlightning/` namespace.
- You want to share a recipe that assembles existing components for a focused agent training or optimization workflow and needs more context than the main examples directory allows.
- You need automation scripts or download helpers that will help the community but should not live under `scripts/` at the repo root.
If a contribution starts depending on core release cadence, tight CI guarantees, or repo-wide infrastructure, talk to maintainers about graduating it out of `contrib/`.
## Directory map
- `agentlightning/` — Namespace packages, utilities, and adapters that extend the published wheel. Place new code under `agentlightning/contrib/<feature>/` so `import agentlightning.contrib.<feature>` works for downstream users.
- `recipes/` — Task-focused example bundles that solve a specific problem and derive certain results. Each recipe belongs in its own directory with a README that documents usage, result reports, and ownership.
- `scripts/` — Shared automation, dataset download steps, or reproducibility helpers that support the contrib modules above.
When adding folders, document the intent in a local README, link to companion docs or examples, and update `CODEOWNERS` so future fixes reach the right reviewers quickly.
Questions or proposals for new subtrees can be discussed in Discord, GitHub issues, or GitHub Discussions before opening a PR. For the canonical requirements and review checklist, see the “Agent-lightning Contrib” section of [`docs/community/contributing.md`](../docs/community/contributing.md).
@@ -0,0 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
# Namespace package for agentlightning.contrib.
@@ -2,7 +2,7 @@
## Overview
This example implements **Search R1** within Agent Lightning. It also serves as a demonstration of a **framework-free agent training pipeline**, showing how to run end-to-end RL training without relying on specialized frameworks. **It's tested and compatible with Agent-lightning v0.1.x**.
This example implements **Search R1** within Agent Lightning. It also serves as a demonstration of a **framework-free agent training pipeline**, showing how to run end-to-end RL training without relying on specialized frameworks. **It's tested and compatible with Agent-lightning v0.1.2**.
The example is designed to run on a single node with 8 GPUs, each having at least 40 GB of memory.
+3
View File
@@ -130,3 +130,6 @@ dist
.pnp.*
.DS_Store
# Storybook build output
storybook-static
@@ -3,6 +3,8 @@
import { useMemo, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@tabler/icons-react';
import { waitFor, within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { Box, Stack, TextInput, Title } from '@mantine/core';
import type { Span } from '@/types';
import { compareRecords } from '@/utils/table';
@@ -365,3 +367,224 @@ export const NestedSpans: Story = {
<TracesTableStoryWrapper maxWidth={1200} spans={sampleSpans.filter((s) => s.traceId === 'trace-nested456')} />
),
};
// Test data with sequence IDs that would sort incorrectly if treated as strings
const sequenceSortTestSpans: Span[] = [
{
rolloutId: 'ro-seq-test',
attemptId: 'at-seq-test',
sequenceId: 2,
traceId: 'trace-seq-002',
spanId: 'span-seq-002',
parentId: null,
name: 'task_sequence_2',
status: { status_code: 'OK', description: null },
attributes: {},
startTime: now - 200,
endTime: now - 190,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-seq-test',
attemptId: 'at-seq-test',
sequenceId: 10,
traceId: 'trace-seq-010',
spanId: 'span-seq-010',
parentId: null,
name: 'task_sequence_10',
status: { status_code: 'OK', description: null },
attributes: {},
startTime: now - 180,
endTime: now - 170,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-seq-test',
attemptId: 'at-seq-test',
sequenceId: 3,
traceId: 'trace-seq-003',
spanId: 'span-seq-003',
parentId: null,
name: 'task_sequence_3',
status: { status_code: 'OK', description: null },
attributes: {},
startTime: now - 160,
endTime: now - 150,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-seq-test',
attemptId: 'at-seq-test',
sequenceId: 11,
traceId: 'trace-seq-011',
spanId: 'span-seq-011',
parentId: null,
name: 'task_sequence_11',
status: { status_code: 'OK', description: null },
attributes: {},
startTime: now - 140,
endTime: now - 130,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-seq-test',
attemptId: 'at-seq-test',
sequenceId: 9,
traceId: 'trace-seq-009',
spanId: 'span-seq-009',
parentId: null,
name: 'task_sequence_9',
status: { status_code: 'OK', description: null },
attributes: {},
startTime: now - 120,
endTime: now - 110,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-seq-test',
attemptId: 'at-seq-test',
sequenceId: 12,
traceId: 'trace-seq-012',
spanId: 'span-seq-012',
parentId: null,
name: 'task_sequence_12',
status: { status_code: 'OK', description: null },
attributes: {},
startTime: now - 100,
endTime: now - 90,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-seq-test',
attemptId: 'at-seq-test',
sequenceId: 4,
traceId: 'trace-seq-004',
spanId: 'span-seq-004',
parentId: null,
name: 'task_sequence_4',
status: { status_code: 'OK', description: null },
attributes: {},
startTime: now - 80,
endTime: now - 70,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-seq-test',
attemptId: 'at-seq-test',
sequenceId: 6,
traceId: 'trace-seq-006',
spanId: 'span-seq-006',
parentId: null,
name: 'task_sequence_6',
status: { status_code: 'OK', description: null },
attributes: {},
startTime: now - 60,
endTime: now - 50,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-seq-test',
attemptId: 'at-seq-test',
sequenceId: 7,
traceId: 'trace-seq-007',
spanId: 'span-seq-007',
parentId: null,
name: 'task_sequence_7',
status: { status_code: 'OK', description: null },
attributes: {},
startTime: now - 40,
endTime: now - 30,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-seq-test',
attemptId: 'at-seq-test',
sequenceId: 13,
traceId: 'trace-seq-013',
spanId: 'span-seq-013',
parentId: null,
name: 'task_sequence_13',
status: { status_code: 'OK', description: null },
attributes: {},
startTime: now - 20,
endTime: now - 10,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
{
rolloutId: 'ro-seq-test',
attemptId: 'at-seq-test',
sequenceId: 14,
traceId: 'trace-seq-014',
spanId: 'span-seq-014',
parentId: null,
name: 'task_sequence_14',
status: { status_code: 'UNSET', description: null },
attributes: {},
startTime: now - 5,
endTime: now,
events: [],
links: [],
context: {},
parent: null,
resource: {},
},
];
export const SequenceIdSortTest: Story = {
render: () => <TracesTableStoryWrapper maxWidth={1200} spans={sequenceSortTestSpans} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const seqHeader = await canvas.findByRole('button', { name: /seq\./i });
await userEvent.click(seqHeader);
await waitFor(() => {
const rows = canvas.getAllByRole('row');
const firstRow = rows[1];
if (!firstRow) {
throw new Error('Expected at least one data row after sorting by sequence ID');
}
within(firstRow).getByText('task_sequence_14');
});
},
};
@@ -23,6 +23,7 @@ export const selectTracesViewMode = (state: RootState) => selectTracesState(stat
const TRACES_SORT_FIELD_MAP: Record<string, string> = {
name: 'name',
sequenceId: 'sequence_id',
traceId: 'trace_id',
spanId: 'span_id',
parentId: 'parent_id',
+29
View File
@@ -0,0 +1,29 @@
services:
prometheus:
image: prom/prometheus:latest
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
volumes:
- ./prometheus/prometheus.base.yml:/etc/prometheus/prometheus.yml:ro
- ${AGL_MONITORING_DATA_PATH:?Set AGL_MONITORING_DATA_PATH to the metrics directory}/prometheus:/prometheus
ports:
- "9090:9090"
grafana:
image: grafana/grafana:latest
depends_on:
- prometheus
ports:
- "9091:3000"
volumes:
- ./data/grafana:/var/lib/grafana
- ./grafana/datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml
- ./grafana/dashboard-provider.yml:/etc/grafana/provisioning/dashboards/provider.yml
- ./grafana/dashboards:/var/lib/grafana/dashboards
environment:
- GF_INSTALL_PLUGINS=grafana-piechart-panel
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_AUTH_DISABLE_LOGIN_FORM=true
- GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/agentlightning.json
+30 -2
View File
@@ -4,7 +4,27 @@ services:
file: compose.store.yml
service: app
command: agl store --host 0.0.0.0 --port 4747 --prometheus --backend memory
depends_on:
- app-exporter # Wait for the exporter to be ready first
command: agl store --host 0.0.0.0 --port 4747 --tracker console prometheus --backend memory
environment:
- PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus_multiproc
volumes:
- prometheus_multiproc:/tmp/prometheus_multiproc
app-exporter:
build:
context: ../
dockerfile: docker/Dockerfile.dev
command: agl prometheus --host 0.0.0.0 --port 4748
ports:
- "4748:4748"
environment:
- PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus_multiproc
volumes:
- prometheus_multiproc:/tmp/prometheus_multiproc
node-exporter:
image: prom/node-exporter:latest
@@ -22,10 +42,11 @@ services:
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=1h"
volumes:
- ./prometheus.memory-store.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus/prometheus.base.yml:/etc/prometheus/prometheus.yml:ro
- ./data/prometheus:/prometheus
depends_on:
- app
- app-exporter
- node-exporter
ports:
- "9090:9090"
@@ -51,3 +72,10 @@ services:
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_AUTH_DISABLE_LOGIN_FORM=true
- GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/agentlightning.json
volumes:
prometheus_multiproc:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
+27 -4
View File
@@ -21,18 +21,33 @@ services:
depends_on:
- mongo
- app-exporter # Wait for the exporter to be ready first
command:
- /bin/bash
- -c
- |
mkdir -p /tmp/prometheus &&
agl store --host 0.0.0.0 --port 4747 \
--prometheus --backend mongo \
--tracker console prometheus --backend mongo \
--mongo-uri mongodb://mongo:27017/?replicaSet=rs0 \
--n-workers ${AGL_STORE_N_WORKERS:-32}
environment:
- PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus
- PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus_multiproc
volumes:
- prometheus_multiproc:/tmp/prometheus_multiproc
app-exporter:
build:
context: ../
dockerfile: docker/Dockerfile.dev
command: agl prometheus --host 0.0.0.0 --port 4748
ports:
- "4748:4748"
environment:
- PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus_multiproc
volumes:
- prometheus_multiproc:/tmp/prometheus_multiproc
mongodb-exporter:
image: percona/mongodb_exporter:0.47.1
@@ -61,10 +76,11 @@ services:
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=1h"
volumes:
- ./prometheus.mongo-store.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus/prometheus.mongo.yml:/etc/prometheus/prometheus.yml:ro
- ./data/prometheus:/prometheus
depends_on:
- app
- app-exporter
- mongodb-exporter
- node-exporter
ports:
@@ -91,3 +107,10 @@ services:
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_AUTH_DISABLE_LOGIN_FORM=true
- GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/agentlightning.json
volumes:
prometheus_multiproc:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,11 @@
global:
scrape_interval: 2s
evaluation_interval: 2s
scrape_interval: 5s
evaluation_interval: 5s
scrape_configs:
- job_name: app
static_configs:
- targets: ["app:4747"]
- targets: ["app-exporter:4748"]
metrics_path: /v1/prometheus/
- job_name: node
@@ -1,11 +1,11 @@
global:
scrape_interval: 2s
evaluation_interval: 2s
scrape_interval: 5s
evaluation_interval: 5s
scrape_configs:
- job_name: app
static_configs:
- targets: ["app:4747"]
- targets: ["app-exporter:4748"]
metrics_path: /v1/prometheus/
- job_name: node
+41 -11
View File
@@ -28,7 +28,7 @@ Documentation improvements are the easiest way to get started. You can find more
Bug fixes are the fastest way to get familiar with the codebase. To get started, you can:
- Browse the ["good first issue"](https://github.com/microsoft/agent-lightning/labels/good%20first%20issue) and ["bug"](https://github.com/microsoft/agent-lightning/labels/bug) labels; drop a comment before you start so we can mark it as taken.
- Browse the ["help wanted"](https://github.com/microsoft/agent-lightning/labels/help%20wanted) and ["bug"](https://github.com/microsoft/agent-lightning/labels/bug) labels; drop a comment before you start so we can mark it as taken.
- For fresh bugs, open an issue with reproduction steps, logs, and expected behavior before submitting a fix.
- Keep each pull request focused, ideally avoiding breaking API changes. Larger refactors should be discussed via RFC or maintainer sync.
@@ -46,6 +46,7 @@ Bonus points for examples that:
- Ship CI or self-test coverage so we know they still work as the core evolves. **Otherwise, we would have to mark the example as unmaintained because we won't be able to test the examples manually before each release.**
- Include a [`docs/how-to/`]({{ src("docs/how-to/") }}) guide (or a detailed README if no how-to exists) without duplicating content in multiple places.
- Favor simple, dependency-light code over heavy abstractions.
- Ship a README that documents smoke-test instructions and includes an "Included Files" section summarizing every file and its role; keep the runnable module self-contained with a module-level docstring explaining CLI usage, plus targeted docstrings or inline comments for educational functions/classes.
!!! warning "Please discuss first"
@@ -74,6 +75,27 @@ Most brand-new algorithms ultimately land as “new examples,” so read that se
Have a project that builds on Agent-lightning but does not belong in the main repo? Fork it or depend on it externally, then let us know. We can showcase notable projects in [Community Projects](../index.md) and the main [README]({{ src("README.md") }}).
### Agent-lightning Contrib
[`contrib/`]({{ src("contrib") }}) is where work-in-progress or third-party integrations, and curated recipes live before they are hardened enough for the core runtime tree. Think of it as an incubator: additions should remain easy to consume, clearly owned, and scoped so downstream users can vendor them with minimal risk.
The following types of contributions are welcome in the contrib area:
- **Recipes** that assemble multiple Agent Lightning components for a narrow task (`contrib/recipes/<topic>/`). Each recipe must be self-contained, include running instructions and result reports.
- **Runtime extensions** that would bloat the primary `agentlightning/` namespace (`contrib/agentlightning/contrib/<feature>/`). These should mirror the published wheel layout so that `import agentlightning.contrib.<feature>` works out of the box.
- **Supporting scripts and assets** (`contrib/scripts/`) that automate dataset downloads, environment preparation, or benchmarks required by contrib modules.
If you are unsure where a contribution should live, start a thread in Discord or open an issue before writing code. The [contrib README]({{ src("contrib/README.md") }}) also lists the directory expectations.
A quick checklist for contributions to be accepted:
1. **Document everything.** Include configuration steps, environment variables, and sample commands so contributors can reproduce the results without guesswork. Pin to a specific version of Agent-lightning and other dependencies to avoid unexpected changes if you don't want to update the recipe frequently.
2. **Keep quality predictable.** Match the repos style guide, apply exhaustive type hints, and run `uv run --no-sync pyright` plus targeted `pytest` suites for any Python module you touch.
3. **Ship reproducibility artifacts.** Store only scripts or instructions for downloading datasets, weights, or binaries. Never upload large artifacts or credentials directly.
4. **Update ownership.** Add `CODEOWNERS` entries when new directories appear so maintainers know who can review follow-up fixes.
Contrib entries do not need the same maturity level as core code, but they must still meet the baseline above. Submissions that lack documentation, hide ownership, or depend on untracked assets are typically rejected until those gaps are resolved.
### Other Contribution Ideas
- **Tests.** Add or improve cases in [`tests/`]({{ src("tests") }}) (unit, integration, or end-to-end).
@@ -126,13 +148,13 @@ After `uv sync`, run commands via `uv run ...` (add `--no-sync` once the environ
Formatting and linting are enforced through [pre-commit](https://pre-commit.com/). Install once, then run before each push:
```bash
uv run pre-commit install
uv run pre-commit run --all-files --show-diff-on-failure --color=always
uv run --no-sync pre-commit install
uv run --no-sync pre-commit run --all-files --show-diff-on-failure --color=always
```
Once installed, the hooks run automatically on every `git commit`. Running the pre-commit hooks locally keeps CI green and diffs manageable.
### 3. Branch From a Fresh `main`
### 3. Branch from Fresh `main` and Code
Start all work from the latest upstream state:
@@ -165,20 +187,28 @@ Use lowercase with hyphens, e.g., `feature/async-runner-hooks`.
Remember to register new docs in [`mkdocs.yml`]({{ src("mkdocs.yml") }}), add examples to [examples/README]({{ src("examples/README.md") }}), and update the [Examples Catalog](../how-to/examples-catalog.md).
Before you start coding, bring the shared coding conventions with you:
- Target `requires-python >= 3.10`, four-space indentation, ~120-character lines (docstrings may run longer), and formatter-owned diffs (Black + isort with the `black` profile).
- Use `snake_case` for modules, functions, and variables; `PascalCase` for classes and React components; lowercase hyphenation for CLI flags, branch names, and TypeScript filenames.
- Maintain exhaustive type hints (pyright enforces them), write succinct Google-style docstrings (with `[][]` cross-references).
- Prefer dataclasses or Pydantic models from `agentlightning.types`.
- Log via `logging.getLogger(__name__)` with targeted DEBUG/INFO/WARNING/ERROR calls—especially for long multi-step functions or broad `try/except` blocks.
### 4. Test and Validate
Most contributions require automated checks. Prefix commands with `uv run` so they use the project environment.
Most contributions require automated checks. Once `uv sync` locks dependencies, prefix commands with `uv run --no-sync ...` so they share the same environment as CI.
**Full test suite**
```bash
uv run pytest -v
uv run --no-sync pytest -v
```
**Targeted tests**
```bash
uv run pytest tests/path/to/test_file.py -k test_name
uv run --no-sync pytest tests/path/to/test_file.py -k test_name
```
**Optional/gated tests:** GPU-specific suites or API-dependent tests run automatically when the required hardware or environment variables (such as `OPENAI_API_KEY`) are present.
@@ -186,7 +216,7 @@ uv run pytest tests/path/to/test_file.py -k test_name
**Static analysis:**
```bash
uv run pyright
uv run --no-sync pyright
```
If you have touched code under `examples/`, you should run the example-specific smoke tests. Each directory includes a README with example-specific smoke tests—run those too.
@@ -196,8 +226,8 @@ If you have touched code under `examples/`, you should run the example-specific
Keep API references under [docs/reference]({{ src("docs/reference/") }}) up to date. Doc-only changes should still build cleanly:
```bash
uv run mkdocs serve --strict # live reload
uv run mkdocs build --strict # CI-equivalent
uv run --no-sync mkdocs serve --strict # live reload
uv run --no-sync mkdocs build --strict # CI-equivalent
```
`--strict` elevates warnings to errors so you catch issues before CI.
@@ -205,7 +235,7 @@ If you have touched code under `examples/`, you should run the example-specific
Before opening a PR, double-check the basics:
- Run `uv lock` if you changed dependencies.
- Run `uv run pre-commit run --all-files` (hooks installed via `pre-commit install` run automatically on `git commit`, but rerun them if you amended history).
- Run `uv run --no-sync pre-commit run --all-files --show-diff-on-failure` (hooks installed via `pre-commit install` run automatically on `git commit`, but rerun them if you amended history).
- Execute the relevant commands from the test list above.
- Validate each affected example via its README instructions.
+2 -2
View File
@@ -117,7 +117,7 @@ rollout = await store.enqueue_rollout(input, config=cfg)
| ------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------- |
| N/A | `queuing` | Created by `enqueue_rollout()`. |
| `preparing` | `queuing/requeuing``preparing` | Typically `dequeue_rollout()` or `start_rollout()`/`start_attempt()` creates a new attempt. |
| `running` | `preparing/queuing/requeuing``running` | First `add_[otel_]span()` flips the attempt to `running`; rollout follows via `propagate_status`. |
| `running` | `preparing/queuing/requeuing``running` | First `add_[otel_]span()` flips the attempt to `running`; rollout follows via `rollout_status_from_attempt`. |
| `succeeded` | `*``succeeded` | Terminal. Rollout `end_time` set. |
| `failed` / `timeout` / `unresponsive` | `*``requeuing` | **Only if** `status ∈ retry_condition ∧ sequence_id < max_attempts`. |
| `failed` / `timeout` / `unresponsive` | `*``failed` | Otherwise (no retries left or retries disabled). |
@@ -125,7 +125,7 @@ rollout = await store.enqueue_rollout(input, config=cfg)
!!! note "Why aggregation?"
In code, we use `propagate_status()` which actively updates the rollout based on the latest attempt. Reading the table above is usually easier than reverse-engineering the propagation logic in the code: think of the rollouts transitions as *callbacks* on attempt state changes, plus queue/cancel paths.
In code, we use `rollout_status_from_attempt()` which actively updates the rollout based on the latest attempt. Reading the table above is usually easier than reverse-engineering the propagation logic in the code: think of the rollouts transitions as *callbacks* on attempt state changes, plus queue/cancel paths.
## Spans
+16
View File
@@ -30,6 +30,22 @@
[:octicons-repo-24: Browse source]({{ src("examples/calc_x") }})
- :material-chart-box:{ .lg .middle } __ChartQA vision-language RL__
---
LangGraph-powered workflow for answering chart questions end to end: rollout the multi-modality agent with GPT or vLLM, and train with VERL/GRPO plus self-refinement loops.
[:octicons-repo-24: Browse source]({{ src("examples/chartqa") }})
- :material-code-braces:{ .lg .middle } __Claude Code SWE-bench__
---
Instrumented driver that runs Anthropic's Claude Code workflow on SWE-bench instances while streaming traces through Agent-lightning—supports hosted vLLM, official Anthropic, or any OpenAI-compatible backend and emits datasets for downstream tuning.
[:octicons-repo-24: Browse source]({{ src("examples/claude_code") }})
- :material-view-grid:{ .lg .middle } __Minimal building blocks__
---
+2
View File
@@ -35,6 +35,8 @@ This documentation is organized into the following parts:
- [DeepWerewolf](https://github.com/af-74413592/DeepWerewolf) — A case study of agent RL training for the Chinese Werewolf game built with AgentScope and Agent Lightning.
- [AgentFlow](https://agentflow.stanford.edu/) — A modular multi-agent framework that combines planner, executor, verifier, and generator agents with the Flow-GRPO algorithm to tackle long-horizon, sparse-reward tasks.
- [Youtu-Agent](https://github.com/TencentCloudADP/Youtu-agent) — Youtu-Agent lets you build and train your agent with ease. Built with [a modified branch](https://github.com/microsoft/agent-lightning/tree/contrib/youtu-agent-lightning) of Agent Lightning, Youtu-Agent has verified up to 128 GPUs RL training on maths/code and search capabilities with steady convergence. Also check [the recipe](https://github.com/TencentCloudADP/youtu-agent/tree/rl/agl).
## Citation
+2
View File
@@ -22,6 +22,8 @@
## Emitter
::: agentlightning.operation
::: agentlightning.emit_annotation
::: agentlightning.emit_reward
+26 -5
View File
@@ -14,17 +14,18 @@
## agl
```text
usage: agl [-h] {vllm,store,agentops}
usage: agl [-h] {vllm,store,prometheus,agentops}
Agent Lightning CLI entry point.
Available subcommands:
vllm Run the vLLM CLI with Agent Lightning instrumentation.
store Run a LightningStore server.
agentops Start the AgentOps server manager.
vllm Run the vLLM CLI with Agent Lightning instrumentation.
store Run a LightningStore server.
prometheus Serve Prometheus metrics from the multiprocess registry.
agentops Start the AgentOps server manager.
positional arguments:
{vllm,store,agentops}
{vllm,store,prometheus,agentops}
Subcommand to run.
options:
@@ -73,6 +74,26 @@ options:
--port PORT Port to run the server on
```
## agl prometheus
Expose the Prometheus multiprocess registry on a dedicated FastAPI server. This is useful when the main LightningStore service is under heavy load; exporters can scrape this auxiliary endpoint instead.
```text
usage: agl prometheus [-h] [--host HOST] [--port PORT] [--metrics-path METRICS_PATH] [--log-level {DEBUG,INFO,WARNING,ERROR}] [--access-log]
Serve Prometheus metrics outside the LightningStore server.
options:
-h, --help show this help message and exit
--host HOST Host to bind the metrics server to.
--port PORT Port to expose the Prometheus metrics on.
--metrics-path METRICS_PATH
HTTP path used to expose metrics. Must start with '/' and not be the root path.
--log-level {DEBUG,INFO,WARNING,ERROR}
Configure the logging level for the metrics server.
--access-log Enable uvicorn access logs. Disabled by default to reduce noise.
```
## agl agentops
Start a mock AgentOps server to bypass the online service of AgentOps.
+19 -1
View File
@@ -24,6 +24,8 @@
::: agentlightning.litagent.decorator.prompt_rollout
::: agentlightning.emitter.annotation.OperationContext
## LLM Proxy
::: agentlightning.llm_proxy.ModelConfig
@@ -44,7 +46,9 @@
::: agentlightning.store.base.UNSET
::: agentlightning.store.utils.propagate_status
::: agentlightning.store.utils.rollout_status_from_attempt
::: agentlightning.store.utils.scan_unhealthy_rollouts
## Tracing and OpenTelemetry
@@ -52,6 +56,20 @@
## Utilities
::: agentlightning.utils.metrics.MetricsBackend
::: agentlightning.utils.metrics.ConsoleMetricsBackend
::: agentlightning.utils.metrics.PrometheusMetricsBackend
::: agentlightning.utils.metrics.MultiMetricsBackend
::: agentlightning.utils.metrics.setup_multiprocess_prometheus
::: agentlightning.utils.metrics.get_prometheus_registry
::: agentlightning.utils.metrics.shutdown_metrics
::: agentlightning.utils.server_launcher.PythonServerLauncher
::: agentlightning.utils.server_launcher.PythonServerLauncherArgs
+4
View File
@@ -20,6 +20,10 @@
## Collections and Collection Implementations
::: agentlightning.store.collection.AtomicMode
::: agentlightning.store.collection.AtomicLabels
::: agentlightning.store.collection.Collection
::: agentlightning.store.collection.Queue
+2
View File
@@ -22,6 +22,8 @@
::: agentlightning.Rollout
::: agentlightning.EnqueueRolloutRequest
::: agentlightning.Attempt
::: agentlightning.AttemptedRollout
+54 -1
View File
@@ -211,6 +211,8 @@ While returning a single float for the final reward is sufficient for many algor
Agent-lightning provides an **emitter** module that allows you to record custom spans from within your agent's logic. Like many common operations (like LLM calls) that are automatically instrumented by [Tracer][agentlightning.Tracer], the emitter will also send a [Span][agentlightning.Span] that records an Agent-lightning-specific operation. Then algorithms can query and read those spans later. See [Working with Traces](./traces.md) for more details.
For multi-step routines (function calls, tools, or adapters) you can wrap code with [`operation`][agentlightning.operation], either as a decorator or a context manager,to capture inputs, outputs, and metadata on a dedicated `"agentlightning.operation"` span. This makes it easier to correlate downstream annotations (like rewards or messages) with the higher-level work that produced them.
You can find the emitter functions from [agentlightning.emitter](../reference/agent.md).
### Emitting Rewards, Messages, and More
@@ -221,7 +223,6 @@ Here are the primary emitter functions:
* [`emit_message(message: str)`][agentlightning.emit_message]: Records a simple log message as a span.
* [`emit_exception(exception: BaseException)`][agentlightning.emit_exception]: Records a Python exception, including its type, message, and stack trace.
* [`emit_object(obj: Any)`][agentlightning.emit_object]: Records any JSON-serializable object, perfect for structured data.
Let's see an example of an agent using these emitters to provide detailed feedback.
```python
@@ -256,3 +257,55 @@ def multi_step_agent(task: dict, prompt_template: PromptTemplate) -> float:
```
By using the emitter, you create a rich, detailed trace of your agent's execution. This data can be invaluable for debugging and is essential for advanced algorithms that can learn from more than just a single final score.
### Linking to Other Spans
Sometimes a span should explicitly point back to another span that produced the input it is working on (for example, linking a reward annotation to the `"agentlightning.operation"` span that generated a response). Agent-lightning encodes these relationships through flattened link attributes. The helper [`make_link_attributes`][agentlightning.utils.otel.make_link_attributes] converts a dictionary of keys—such as `trace_id`, `span_id`, or any custom attribute—into the `"agentlightning.link.*"` fields expected by the backend. Later on, [`query_linked_spans`][agentlightning.utils.otel.query_linked_spans] can be used to recover the original span(s) from those link descriptors.
```python
import opentelemetry.trace as trace_api
from agentlightning import emit_annotation, operation
from agentlightning.utils.otel import make_link_attributes, make_tag_attributes
with operation(conversation_id="chat-42") as op:
# ... perform the work ...
span_ctx = op.span.get_span_context()
link_attrs = make_link_attributes({
"conversation_id": "chat-42",
})
emit_annotation(
{
**link_attrs,
**make_tag_attributes(["reward", "good"]),
}
)
```
When analyzing in adapters, pass the extracted link models to [`query_linked_spans`][agentlightning.utils.otel.query_linked_spans] to retrieve the matching span(s):
```python
from agentlightning.utils.otel import extract_links_from_attributes, query_linked_spans
annotation_span = ... # Span from your trace store
operation_spans = [...] # list of spans you want to search
link_models = extract_links_from_attributes(annotation_span.attributes)
matches = query_linked_spans(operation_spans, link_models)
assert matches # Contains the original operation span
```
!!! tip "Correlating Rewards with LLM Requests"
[Tracer](./traces.md) instruments each request/response as its own span. You can link to the [`gen_ai.response.id`](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-events/) attribute, which comes from the LLM response ID.
```python
from agentlightning import emit_reward
from agentlightning.utils.otel import make_link_attributes
result = call_llm(prompt)
reward_links = make_link_attributes({"gen_ai.response.id": result.id})
emit_reward(0.9, attributes=reward_links)
```
Later, use the same `gen_ai.response.id` key inside `query_linked_spans` to find the reward(s) that reference that specific LLM request span.
+3
View File
@@ -12,3 +12,6 @@ unsloth/unsloth_training_checkpoints/
apo/pomltrace/
tinker/logs/
tinker/crewai_*.html
rag/dataset_tiny.parquet
rag/chunks_candidate_tiny.pkl
rag/index_hnsw_faiss_n32e40_tiny.index
+21 -3
View File
@@ -2,16 +2,34 @@
This catalog highlights the examples shipped with Agent-lightning.
Community-contributed examples and recipes are available in the [contrib](../contrib) directory.
| Example | Description | CI Maintenance |
|---------|-------------|----------------|
| [apo](./apo) | Automatic Prompt Optimization tutorials covering built-in, custom, and debugging workflows. | [![apo workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-apo.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-apo.yml) |
| [azure](./azure) | Supervised fine-tuning with Azure OpenAI. | [![azure workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-azure.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-azure.yml) |
| [calc_x](./calc_x) | VERL-powered math reasoning agent training that uses AutoGen with an MCP calculator tool. | [![calc_x workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-calc-x.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-calc-x.yml) |
| [chartqa](./chartqa) | Vision-language ChartQA agent that reasons over charts with LangGraph and VERL plus multi-step self-refinement. | [![chartqa workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-chartqa.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-chartqa.yml) |
| [claude_code](./claude_code) | Claude Code SWE-bench harness that records Agent-lightning traces across Anthropic, vLLM, and OpenAI-compatible backends. | [![claude_code workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-claude-code.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-claude-code.yml) |
| [minimal](./minimal) | Bite-sized programs that demonstrate how individual Agent-lightning building blocks behave in isolation. | [![minimal workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-unit.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/badge-unit.yml) |
| [rag](./rag) | Retrieval-Augmented Generation pipeline targeting the MuSiQue dataset with Wikipedia retrieval. | **Unmaintained** — last verified with Agent-lightning v0.1.1 |
| [search_r1](./search_r1) | Framework-free Search-R1 reinforcement learning training workflow with a retrieval backend. | **Unmaintained** — last verified with Agent-lightning v0.1.2 |
| [rag](./rag) | Retrieval-Augmented Generation pipeline targeting the MuSiQue dataset with Wikipedia retrieval. | [![rag workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-rag.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-rag.yml) |
| [spider](./spider) | Text-to-SQL reinforcement learning training on the Spider dataset using LangGraph. | [![spider workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-spider.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-spider.yml) |
| [tinker](./tinker) | Reinforcement learning with Tinker as the backend training service. | [![tinker workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-tinker.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-tinker.yml) |
| [unsloth](./unsloth) | Supervised fine-tuning example powered by Unsloth with 4-bit quantization and LoRA. | [![unsloth workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-unsloth.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-unsloth.yml) |
*NOTE: CI status avoids taking any workflow running with latest dependencies into account. That's why we reference the corresponding `badge-*` workflows instead. Each example's own README also displays its `examples-*` workflow status whenever the project is maintained by CI.*
## `examples-*` workflow status
CI status above avoids taking any workflow running with latest dependencies into account. That's why we reference the corresponding `badge-*` workflows instead. The following table displays the raw `examples-*` workflow status whenever the project is maintained by CI.
| Workflow | Status |
|----------|--------|
| `examples-apo.yml` | [![examples-apo workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/examples-apo.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-apo.yml) |
| `examples-azure.yml` | [![examples-azure workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/examples-azure.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-azure.yml) |
| `examples-calc-x.yml` | [![examples-calc-x workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/examples-calc-x.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-calc-x.yml) |
| `examples-chartqa.yml` | [![examples-chartqa workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/examples-chartqa.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-chartqa.yml) |
| `examples-claude-code.yml` | [![examples-claude-code workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/examples-claude-code.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-claude-code.yml) |
| `examples-compat.yml` | [![examples-compat workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/examples-compat.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-compat.yml) |
| `examples-rag.yml` | [![examples-rag workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/examples-rag.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-rag.yml) |
| `examples-spider.yml` | [![examples-spider workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/examples-spider.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-spider.yml) |
| `examples-tinker.yml` | [![examples-tinker workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/examples-tinker.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-tinker.yml) |
| `examples-unsloth.yml` | [![examples-unsloth workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/examples-unsloth.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-unsloth.yml) |
+2
View File
@@ -1,5 +1,7 @@
# Supervised Fine-tuning with Azure OpenAI
[![azure CI status](https://github.com/microsoft/agent-lightning/actions/workflows/examples-azure.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-azure.yml)
This example walks through an end-to-end supervised fine-tuning loop on Azure OpenAI. The trainer runs a toy capital-lookup agent, collects traces with rewards, submits fine-tuning jobs using those traces, and deploys every successful checkpoint as a new Azure OpenAI deployment.
**NOTE: The example is tested and compatible with Agent-lightning v0.2.x, but it's not yet maintained on CI due to the difficulty of maintaining a logged-in status in the testing environment.**
+35
View File
@@ -117,6 +117,9 @@ def train(
ci_fast: bool,
n_runners: int,
external_store_address: str,
lora: bool,
lora_rank: int,
lora_adapter_path: Optional[str],
):
"""The training entrypoint function for Calc-X agent with VERL algorithm.
@@ -129,6 +132,9 @@ def train(
n_runners: The number of runners for the Trainer.
ci_fast: Whether to cap the training loop at a single step (implies CI toggles).
external_store_address: Connects to an external store instead of creating a new one in memory.
lora: Whether to enable LoRA training.
lora_rank: LoRA rank to use when LoRA is enabled.
lora_adapter_path: Optional path to a pre-trained LoRA adapter to load.
"""
# Load datasets (respect CLI file paths)
train_dataset = cast(agl.Dataset[MathProblem], HuggingFaceDataset.from_parquet(train_file).to_list()) # type: ignore
@@ -144,6 +150,15 @@ def train(
if model:
config["actor_rollout_ref"]["model"]["path"] = model
# Enable LoRA configuration if requested
if lora:
config["actor_rollout_ref"]["model"]["lora_rank"] = lora_rank
print(f"LoRA enabled: lora_rank={lora_rank}")
if lora_adapter_path:
config["actor_rollout_ref"]["model"]["lora_adapter_path"] = lora_adapter_path
print(f"Loading LoRA adapter from: {lora_adapter_path}")
print("LoRA configuration will trigger verl to set ref_in_actor=True (LoRA mode)")
# CI toggle keeps everything else the same but you can tweak the lightweight bits here if desired
if ci or ci_fast:
# Config the experiment name and project name so that they are available to CI
@@ -218,6 +233,23 @@ def main():
help="Connect to an external store instead of creating a new one in memory",
)
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
parser.add_argument(
"--lora",
action="store_true",
help="Enable LoRA training. When enabled, the reference policy is computed by the actor rollout worker.",
)
parser.add_argument(
"--lora-rank",
type=int,
default=32,
help="LoRA rank to use when --lora is enabled (default: 32)",
)
parser.add_argument(
"--lora-adapter-path",
type=str,
default=None,
help="Optional path to a pre-trained LoRA adapter to load when --lora is enabled",
)
args = parser.parse_args()
@@ -243,6 +275,9 @@ def main():
ci_fast=args.ci_fast,
n_runners=args.n_runners,
external_store_address=args.external_store_address,
lora=args.lora,
lora_rank=args.lora_rank,
lora_adapter_path=args.lora_adapter_path,
)
+113
View File
@@ -0,0 +1,113 @@
# ChartQA Example
[![chartqa workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-chartqa.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-chartqa.yml)
This example demonstrates training a visual reasoning agent on the ChartQA dataset using Agent-Lightning with the VERL algorithm and LangGraph framework. The agent answers questions about charts through a multi-step workflow with self-refinement. It's compatible with Agent-lightning v0.3.0 or later.
## Requirements
This example requires a single node with at least one 40GB GPU. Install dependencies with:
```bash
uv sync --frozen \
--group dev \
--group experiment \
--group image \
--group langchain \
--group vllm-0-10-2 \
--group torch-gpu-stable
```
**Currently vLLM 0.10.2 is the only tested version. You might see issues like `cu_seqlens_q must be on CUDA` or flash-attn installation failures if you use other versions.** (See https://github.com/vllm-project/vllm/issues/27340)
## Dataset
Download the ChartQA dataset and prepare it for training:
```bash
cd examples/chartqa
python prepare_data.py
```
This downloads the ChartQA dataset from HuggingFace (`HuggingFaceM4/ChartQA`), saves images locally, and creates parquet files for training/testing. No HuggingFace token is required (public dataset).
**Dataset Statistics:**
- Training: ~18,000 chart question-answer pairs
- Test: ~2,500 pairs
- Chart types: Bar, line, pie, scatter, etc.
## Included Files
| File/Directory | Description |
|----------------|-------------|
| `chartqa_agent.py` | Chart reasoning agent using LangGraph with multi-step workflow (observe → extract → calculate → check → refine) |
| `train_chartqa_agent.py` | Training script using VERL algorithm with configurable hyperparameters (debug, qwen) |
| `debug_chartqa_agent.py` | Debugging script to test the agent with cloud APIs or a local vLLM proxy |
| `prepare_data.py` | Script to download ChartQA dataset from HuggingFace and prepare parquet files |
| `prompts.py` | Prompt templates for the agent workflow |
| `multimodal_utils.py` | Utility functions for encoding images to base64 |
| `env_var.py` | Environment variables and configurations |
| `data/` | Directory containing images and parquet files after download |
## Running Examples
### Debugging with Cloud API (Default)
For quick testing with OpenAI or other cloud APIs (no local GPU required):
```bash
export OPENAI_API_KEY=<your-api-key>
python debug_chartqa_agent.py
```
For other providers (Azure, etc.), set `OPENAI_API_BASE`:
```bash
export OPENAI_API_BASE=https://your-resource.openai.azure.com/v1
export OPENAI_MODEL=gpt-4o
python debug_chartqa_agent.py
```
### Debugging with Local Model (LLMProxy)
To test the agent with a local vLLM server and LLMProxy:
```bash
# Start a vLLM server (specify image path for VLM)
export CHARTQA_DATA_DIR=<path to chartqa data>
vllm serve Qwen/Qwen2-VL-2B-Instruct \
--gpu-memory-utilization 0.6 \
--max-model-len 4096 \
--allowed-local-media-path $CHARTQA_DATA_DIR \
--enable-prefix-caching \
--port 8088
# Run the agent with LLMProxy
USE_LLM_PROXY=1 \
OPENAI_API_BASE=http://localhost:8088/v1 \
OPENAI_MODEL=Qwen/Qwen2-VL-2B-Instruct \
python debug_chartqa_agent.py
```
### Training with Local Model
```bash
python train_chartqa_agent.py debug --n-runners 2
```
You can also use an external store server (recommended for distributed setups), first start the store:
```bash
agl store --port 4747
```
Then run the training script with the external store address:
```bash
AGL_MANAGED_STORE=0 python train_chartqa_agent.py qwen --external-store-address http://localhost:4747
```
If you want to track experiments with Weights & Biases, set the `WANDB_API_KEY` environment variable before training.
The script automatically launches agent workers and the training server. The agent workers execute chart reasoning rollouts using the vision-language model, while the training server applies the VERL algorithm (GRPO) to improve the model based on answer accuracy rewards.
+410
View File
@@ -0,0 +1,410 @@
# Copyright (c) Microsoft. All rights reserved.
"""ChartQA agent demonstrating LangGraph-based visual reasoning with refinement.
This module defines `ChartQAAgent` plus the supporting prompt utilities used by
`debug_chartqa_agent.py` and `train_chartqa_agent.py`.
1. `analyze_chart` observes and summarizes the chart.
2. `extract_data` calls a text-only LLM to extract the requested values.
3. `calculate_answer` runs calculations grounded in prior steps.
4. `check_answer` verifies reasoning quality.
5. `refine_answer` conditionally patches mistakes before responding.
Example usage can be found in `debug_chartqa_agent.py` and `train_chartqa_agent.py`.
"""
from __future__ import annotations
import logging
import os
import re
from typing import Any, Dict, Literal, cast
import env_var as chartqa_env_var
import termcolor
from langchain.chat_models import BaseChatModel, init_chat_model
from langchain_core.messages import AnyMessage, BaseMessage, HumanMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.graph.state import CompiledStateGraph
from multimodal_utils import encode_image_to_base64
from prompts import (
ANALYZE_CHART_PROMPT,
CALCULATE_ANSWER_PROMPT,
CHECK_ANSWER_PROMPT,
EXTRACT_DATA_PROMPT,
REFINE_ANSWER_PROMPT,
)
import agentlightning as agl
logger = logging.getLogger("chartqa_agent")
class ChartState(MessagesState):
question: str
image_path: str
observation: str
extracted_data: str
calculation: str
answer: str
feedback: str
num_turns: int
messages: list[AnyMessage]
class ChartQAAgent(agl.LitAgent[Dict[str, Any]]):
"""LangGraph-powered ChartQA agent with multi-step reasoning and refinement.
The implementation shares the same [`agl.LitAgent`][agentlightning.LitAgent] interface as
the Calc-X sample agent but augments it with image handling and LangGraph state tracking.
"""
def __init__(
self,
model_name: str | None = None,
max_turns: int = 3,
debug: bool = False,
endpoint: str | None = None,
temperature: float = 0.0,
use_base64_images: bool = False,
):
self.debug = debug
self.max_turns = max_turns
self.use_base64_images = use_base64_images
self.model_name = model_name
self.endpoint = endpoint
self.temperature = temperature
self._llm: BaseChatModel | None = None
self._graph: CompiledStateGraph[ChartState] | None = None
def _create_llm(self) -> BaseChatModel:
if self.model_name is None:
raise ValueError("model_name is required for creating LLM")
return init_chat_model(
self.model_name,
model_provider="openai",
openai_api_base=self.endpoint,
openai_api_key=chartqa_env_var.OPENAI_API_KEY,
temperature=self.temperature,
max_retries=2,
max_tokens=1024,
timeout=300,
)
def update_llm_config(self, model_name: str, endpoint: str | None, temperature: float | None) -> None:
"""Update the LLM configuration. Re-create the LLM if the configuration is changed."""
updated: bool = False
if model_name != self.model_name:
self.model_name = model_name
updated = True
if endpoint != self.endpoint:
self.endpoint = endpoint
updated = True
if temperature != self.temperature:
self.temperature = temperature
updated = True
if updated:
self._llm = self._create_llm()
def _ensure_llm(self) -> BaseChatModel:
"""Ensure the LLM is created and cached."""
if self._llm is None:
self._llm = self._create_llm()
return self._llm
def invoke_prompt(self, prompt: Any) -> AnyMessage:
"""Invoke LLM with prompt."""
if self.debug:
for message in prompt.messages:
termcolor.cprint(message.pretty_repr(), "blue")
try:
result = self._ensure_llm().invoke(prompt)
except Exception as e:
logger.error(f"Failed to invoke prompt: {e}")
result = self._ensure_llm().invoke([HumanMessage(content="Please provide a reasonable answer.")])
if self.debug:
termcolor.cprint(result.pretty_repr(), "green")
return result # type: ignore
def invoke_prompt_with_image(self, prompt_text: str, image_path: str) -> str:
"""Invoke vision-language model with image.
Handles both local vLLM (file:// URLs) and cloud APIs (base64 encoding).
Cloud APIs (OpenAI, Anthropic, Google, Azure, etc.) require base64 encoding.
"""
# Determine image URL format based on endpoint
if self.use_base64_images:
# Cloud APIs require base64 encoding for local files
image_url = encode_image_to_base64(image_path)
else:
# Local vLLM supports file:// URLs
if not image_path.startswith("file://"):
image_path = f"file://{os.path.realpath(image_path)}"
image_url = image_path
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": prompt_text},
{"type": "image_url", "image_url": {"url": image_url}},
],
}
]
if self.debug:
termcolor.cprint(f"[VLM Call] {prompt_text[:100]}...", "blue")
try:
result = self._ensure_llm().invoke(messages)
response = result.content if hasattr(result, "content") else str(result) # type: ignore
except Exception as e:
logger.error(f"Failed to invoke VLM: {e}")
response = "<observe>Unable to analyze chart</observe>"
if self.debug:
termcolor.cprint(f"[VLM Response] {response[:200]}...", "green")
return response # type: ignore
def extract_content(self, text: str, tag: str) -> str:
"""Extract content between XML-style tags."""
match = re.search(rf"<{tag}>(.*?)</{tag}>", text, re.DOTALL)
return match.group(1).strip() if match else ""
def analyze_chart(self, state: ChartState) -> ChartState:
"""Step 1: Observe and describe the chart."""
prompt: Any = ANALYZE_CHART_PROMPT.invoke({"question": state["question"]}) # type: ignore
prompt_text = prompt.messages[1].content
result_text = self.invoke_prompt_with_image(prompt_text, state["image_path"])
observation = self.extract_content(result_text, "observe")
if not observation:
observation = result_text
return { # type: ignore
**state,
"observation": observation,
"num_turns": 1,
"messages": [HumanMessage(content=result_text)],
}
def extract_data(self, state: ChartState) -> ChartState:
"""Step 2: Extract specific data values."""
prompt: Any = EXTRACT_DATA_PROMPT.invoke( # type: ignore
{
"observation": state["observation"],
"question": state["question"],
}
)
result = self.invoke_prompt(prompt)
extracted_data = self.extract_content(result.content, "extract") # type: ignore
if not extracted_data:
extracted_data = result.content # type: ignore
return { # type: ignore
**state,
"extracted_data": extracted_data, # type: ignore
"messages": [*state.get("messages", []), result],
}
def calculate_answer(self, state: ChartState) -> ChartState:
"""Step 3: Calculate and provide answer."""
prompt: Any = CALCULATE_ANSWER_PROMPT.invoke( # type: ignore
{
"extracted_data": state["extracted_data"],
"question": state["question"],
}
)
result = self.invoke_prompt(prompt)
calculation = self.extract_content(result.content, "calculate") # type: ignore
answer = self.extract_content(result.content, "answer") # type: ignore
if not answer:
answer = cast(str, result.content) # type: ignore
return { # type: ignore
**state,
"calculation": calculation,
"answer": answer,
"messages": [*state.get("messages", []), result],
}
def check_answer(self, state: ChartState) -> ChartState:
"""Step 4: Verify answer quality."""
prompt: Any = CHECK_ANSWER_PROMPT.invoke( # type: ignore
{
"observation": state["observation"],
"extracted_data": state["extracted_data"],
"question": state["question"],
"answer": state["answer"],
"calculation": state.get("calculation", "No calculation shown"),
}
)
result = self.invoke_prompt(prompt)
if self.debug:
termcolor.cprint(f"[Check] {result.content}", "yellow") # type: ignore
return { # type: ignore
**state,
"feedback": result.content, # type: ignore
"messages": [*state.get("messages", []), *prompt.messages, result],
}
def refine_answer(self, state: ChartState) -> ChartState:
"""Step 5: Refine answer based on feedback."""
prompt: Any = REFINE_ANSWER_PROMPT.invoke( # type: ignore
{
"observation": state["observation"],
"extracted_data": state["extracted_data"],
"question": state["question"],
"answer": state["answer"],
"calculation": state.get("calculation", ""),
"feedback": state["feedback"],
}
)
result = self.invoke_prompt(prompt)
content: str = result.content # type: ignore
new_extracted = self.extract_content(content, "extract")
extracted_data = new_extracted if new_extracted else state["extracted_data"]
new_calculation = self.extract_content(content, "calculate")
new_answer = self.extract_content(content, "answer")
if not new_answer:
new_answer = content
return { # type: ignore
**state,
"extracted_data": extracted_data,
"calculation": new_calculation,
"answer": new_answer,
"num_turns": state.get("num_turns", 0) + 1,
"messages": [*prompt.messages, result],
}
def should_continue(self, state: ChartState) -> Literal[END, "refine_answer"]: # type: ignore
"""Determine if refinement is needed."""
if state["messages"] and isinstance(
state["messages"][-1], BaseMessage
): # pyright: ignore[reportUnnecessaryIsInstance]
last_message = state["messages"][-1]
if "THE ANSWER IS CORRECT" in last_message.content: # type: ignore
if "THE ANSWER IS INCORRECT" in last_message.content: # type: ignore
correct_index = last_message.content.rfind("THE ANSWER IS CORRECT") # type: ignore
incorrect_index = last_message.content.rfind("THE ANSWER IS INCORRECT") # type: ignore
if correct_index > incorrect_index:
return END
else:
return END
if state.get("num_turns", 0) >= self.max_turns:
return END
return "refine_answer"
def graph(self) -> CompiledStateGraph[ChartState]:
"""Build the workflow graph with refinement loop."""
# Check if the graph is already built
if self._graph is not None:
return self._graph
builder = StateGraph(ChartState)
builder.add_node(self.analyze_chart) # type: ignore
builder.add_node(self.extract_data) # type: ignore
builder.add_node(self.calculate_answer) # type: ignore
builder.add_node(self.check_answer) # type: ignore
builder.add_node(self.refine_answer) # type: ignore
builder.add_edge(START, "analyze_chart")
builder.add_edge("analyze_chart", "extract_data")
builder.add_edge("extract_data", "calculate_answer")
builder.add_edge("calculate_answer", "check_answer")
builder.add_conditional_edges(
"check_answer",
self.should_continue, # type: ignore
)
builder.add_edge("refine_answer", "extract_data")
self._graph = builder.compile() # type: ignore
return self._graph
def rollout(self, task: Dict[str, Any], resources: agl.NamedResources, rollout: agl.Rollout) -> float | None:
"""AgentLightning wrapper for ChartQA agent."""
question = task["question"]
rollout = cast(agl.AttemptedRollout, rollout)
llm = cast(agl.LLM, resources["main_llm"])
image_path = os.path.join(chartqa_env_var.CHARTQA_DATA_DIR, task["image_path"])
ground_truth = task["answer"]
if not os.path.exists(image_path):
logger.error(f"Image {image_path} does not exist. Skipping.")
return None
# The new rollout could have a different endpoint or temperature.
# Update the LLM if necessary.
self.update_llm_config(
model_name=llm.model,
endpoint=llm.get_base_url(rollout.rollout_id, rollout.attempt.attempt_id),
temperature=llm.sampling_parameters.get("temperature", 0.0),
)
try:
handler = self.tracer.get_langchain_handler()
result = self.graph().invoke( # type: ignore
{"question": question, "image_path": image_path}, # type: ignore
{"callbacks": [handler] if handler else [], "recursion_limit": 100},
)
except Exception as e:
error_msg = f"[Rollout {rollout.rollout_id}] Error during agent invocation: {e}"
logger.error(error_msg, exc_info=True)
# Return 0.0 as reward to indicate failure
return 0.0
predicted_answer = result["answer"]
reward = evaluate_answer(predicted_answer, ground_truth, raise_on_error=False)
return reward
def evaluate_answer(predicted: str, ground_truth: str, raise_on_error: bool = False) -> float:
"""Evaluate answer accuracy."""
try:
pred = predicted.lower().strip()
gt = ground_truth.lower().strip()
# Exact match
if pred == gt:
return 1.0
# Try numeric comparison
try:
pred_num = float(pred.replace(",", ""))
gt_num = float(gt.replace(",", ""))
if abs(pred_num - gt_num) / max(abs(gt_num), 1e-9) < 0.02:
return 1.0
except (ValueError, AttributeError):
pass
# Partial credit for substring match
if pred in gt or gt in pred:
return 0.5
return 0.0
except Exception as e:
if raise_on_error:
raise
logger.exception(f"Error evaluating answer: {e}")
return 0.0
+129
View File
@@ -0,0 +1,129 @@
# Copyright (c) Microsoft. All rights reserved.
"""Debugging helpers for the ChartQA agent.
Example usage for OpenAI API:
```bash
python debug_chartqa_agent.py
```
Example usage for self-hosted model.
```
vllm serve Qwen/Qwen2-VL-2B-Instruct \
--gpu-memory-utilization 0.6 \
--max-model-len 4096 \
--allowed-local-media-path $CHARTQA_DATA_DIR \
--enable-prefix-caching \
--port 8088
USE_LLM_PROXY=1 OPENAI_API_BASE=http://localhost:8088/v1 OPENAI_MODEL=Qwen/Qwen2-VL-2B-Instruct python debug_chartqa_agent.py
```
Ensure `CHARTQA_DATA_DIR` points to a directory with the prepared parquet file by running `python prepare_data.py` beforehand.
"""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, cast
import env_var as chartqa_env_var
import pandas as pd
from chartqa_agent import ChartQAAgent
import agentlightning as agl
logger = logging.getLogger("chartqa_agent")
def create_llm_proxy_for_chartqa(vllm_endpoint: str, port: int = 8081) -> agl.LLMProxy:
"""Create an LLMProxy configured for ChartQA with token ID capture.
Args:
vllm_endpoint: Base URL for the hosted vLLM server.
port: Local port where the proxy should listen.
Returns:
An [`LLMProxy`][agentlightning.LLMProxy] instance launched in a thread.
"""
store = agl.LightningStoreThreaded(agl.InMemoryLightningStore())
llm_proxy = agl.LLMProxy(
port=port,
store=store,
model_list=[
{
"model_name": "Qwen/Qwen2-VL-2B-Instruct",
"litellm_params": {
"model": "hosted_vllm/Qwen/Qwen2-VL-2B-Instruct",
"api_base": vllm_endpoint,
},
}
],
callbacks=["return_token_ids"],
launch_mode="thread",
)
return llm_proxy
def debug_chartqa_agent(use_llm_proxy: bool = False) -> None:
"""Debug the ChartQA agent against cloud APIs or a local vLLM proxy.
Args:
use_llm_proxy: When `True`, spin up an LLMProxy that points to a local vLLM endpoint.
Raises:
FileNotFoundError: If the prepared ChartQA parquet file is missing.
"""
test_data_path = os.path.join(chartqa_env_var.CHARTQA_DATA_DIR, "test_chartqa.parquet")
if not os.path.exists(test_data_path):
raise FileNotFoundError(f"Test data file {test_data_path} does not exist. Please run prepare_data.py first.")
df = pd.read_parquet(test_data_path).head(10) # type: ignore
test_data = cast(List[Dict[str, Any]], df.to_dict(orient="records")) # type: ignore
model = chartqa_env_var.OPENAI_MODEL
endpoint = chartqa_env_var.OPENAI_API_BASE
logger.info(
"Debug data: %s samples, model: %s, endpoint: %s, llm_proxy=%s",
len(test_data),
model,
endpoint,
use_llm_proxy,
)
llm_endpoint = endpoint
trainer_kwargs: Dict[str, Any] = {}
if use_llm_proxy:
proxy_port = 8089
llm_proxy = create_llm_proxy_for_chartqa(endpoint, port=proxy_port)
trainer_kwargs["llm_proxy"] = llm_proxy
trainer_kwargs["n_workers"] = 2
llm_endpoint = f"http://localhost:{proxy_port}/v1"
agent = ChartQAAgent()
else:
trainer_kwargs["n_workers"] = 1
agent = ChartQAAgent(use_base64_images=True)
trainer = agl.Trainer(
initial_resources={
"main_llm": agl.LLM(
endpoint=llm_endpoint,
model=model,
sampling_parameters={"temperature": 0.0},
)
},
**trainer_kwargs,
)
trainer.dev(agent, test_data)
if __name__ == "__main__":
agl.setup_logging(apply_to=["chartqa_agent"])
debug_chartqa_agent(use_llm_proxy=chartqa_env_var.USE_LLM_PROXY)
+30
View File
@@ -0,0 +1,30 @@
# Copyright (c) Microsoft. All rights reserved.
import os
__all__ = [
"CHARTQA_ROOT_DIR",
"CHARTQA_DATA_DIR",
"CHARTQA_IMAGES_DIR",
"USE_BASE64_IMAGES",
"USE_LLM_PROXY",
"OPENAI_API_BASE",
"OPENAI_API_KEY",
"OPENAI_MODEL",
]
CHARTQA_ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
CHARTQA_DATA_DIR = os.getenv("CHARTQA_DATA_DIR", os.path.realpath(os.path.join(CHARTQA_ROOT_DIR, "data")))
CHARTQA_IMAGES_DIR = os.getenv("CHARTQA_IMAGES_DIR", os.path.realpath(os.path.join(CHARTQA_ROOT_DIR, "data", "images")))
USE_BASE64_IMAGES = os.getenv("USE_BASE64_IMAGES", "false").lower() in ("1", "true", "yes")
USE_LLM_PROXY = os.getenv("USE_LLM_PROXY", "false").lower() in ("1", "true", "yes")
OPENAI_API_BASE = os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "token-abc123")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
+113
View File
@@ -0,0 +1,113 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Multimodal support utilities for Agent Lightning.
This module provides helper functions for working with multimodal agents,
particularly for vision-language tasks.
"""
from __future__ import annotations
import base64
from io import BytesIO
from pathlib import Path
from typing import Any, Union
import requests
from PIL import Image
from PIL.Image import Image as PILImage
__all__ = [
"encode_image_to_base64",
"create_image_message",
]
def encode_image_to_base64(image: Union[str, Path, PILImage], max_size: int = 2048) -> str:
"""
Encode an image to base64 string for multimodal LLM APIs.
Args:
image: Image source (file path, URL, or PIL Image object)
max_size: Maximum dimension for resizing
Returns:
Base64 encoded image string with data URI prefix
Raises:
ImportError: If PIL (Pillow) is not installed
TypeError: If image type is not supported
Examples:
>>> encoded = encode_image_to_base64("photo.jpg")
>>> encoded[:30]
'data:image/jpeg;base64,/9j/4A...'
>>> from PIL import Image
>>> img = Image.open("photo.jpg")
>>> encoded = encode_image_to_base64(img)
"""
# Load image
if isinstance(image, (str, Path)):
image_str = str(image)
if image_str.startswith(("http://", "https://")):
response = requests.get(image_str, timeout=30)
response.raise_for_status()
img = Image.open(BytesIO(response.content))
else:
img = Image.open(image_str)
elif hasattr(image, "mode"):
# PIL Image object
img = image
else:
raise TypeError(f"Unsupported image type: {type(image)}")
# Convert to RGB
if img.mode == "RGBA":
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3])
img = background
elif img.mode != "RGB":
img = img.convert("RGB")
# Resize if needed
if max(img.size) > max_size:
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
# Encode
buffered = BytesIO()
img.save(buffered, format="JPEG", quality=85)
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/jpeg;base64,{img_str}"
def create_image_message(text: str, image: Union[str, Path, PILImage], use_base64: bool = True) -> dict[str, Any]:
"""
Create an OpenAI-compatible multimodal message.
Args:
text: The text prompt/question
image: Image source (path, URL, or PIL Image)
use_base64: If True, encode as base64; if False, use URL directly
Returns:
Message dict with role="user" and multimodal content
Examples:
>>> msg = create_image_message("What's in the image?", "photo.jpg")
>>> msg["role"]
'user'
>>> len(msg["content"])
2
"""
content: list[dict[str, Any]] = [{"type": "text", "text": text}]
if isinstance(image, str) and image.startswith(("http://", "https://")) and not use_base64:
content.append({"type": "image_url", "image_url": {"url": image}})
else:
encoded = encode_image_to_base64(image)
content.append({"type": "image_url", "image_url": {"url": encoded}})
return {"role": "user", "content": content}
+44
View File
@@ -0,0 +1,44 @@
# Copyright (c) Microsoft. All rights reserved.
"""Prepare ChartQA dataset from HuggingFace for training."""
from pathlib import Path
from typing import Any, Dict, List
import pandas as pd
from datasets import load_dataset # pyright: ignore[reportUnknownVariableType]
def prepare_chartqa():
"""Download ChartQA and convert to parquet format."""
data_dir = Path("data")
images_dir = data_dir / "images"
images_dir.mkdir(parents=True, exist_ok=True)
dataset = load_dataset("HuggingFaceM4/ChartQA")
for split in ["train", "test"]:
tasks: List[Dict[str, Any]] = []
dataset_length = len(dataset[split]) # type: ignore
for idx, item in enumerate(dataset[split]): # pyright: ignore[reportUnknownArgumentType]
if idx % 1000 == 0:
print(f"Processing {split} item {idx} (out of {dataset_length})")
image_filename = f"{split}_{idx:06d}.png"
image_path = images_dir / image_filename
if not image_path.exists():
item["image"].save(image_path)
tasks.append(
{
"id": f"{split}_{idx}",
"image_path": f"images/{image_filename}",
"question": item["query"],
"answer": str(item["label"]),
}
)
pd.DataFrame(tasks).to_parquet(data_dir / f"{split}_chartqa.parquet", index=False) # type: ignore
if __name__ == "__main__":
prepare_chartqa()
+198
View File
@@ -0,0 +1,198 @@
# Copyright (c) Microsoft. All rights reserved.
"""Prompts for ChartQA agent workflow."""
from langchain_core.prompts import ChatPromptTemplate
ANALYZE_CHART_PROMPT = ChatPromptTemplate(
[
(
"system",
"""
You are a visual reasoning expert analyzing charts and graphs.
Given a chart image and a question, first carefully observe and describe the chart.
Instructions:
- Identify the chart type (bar chart, line chart, pie chart, scatter plot, etc.)
- Note the axes labels and units (if applicable)
- Describe the data series or categories shown
- Observe key patterns, trends, or noteworthy values
- Pay attention to legends, titles, and annotations
## Output Format ##
Provide your observation inside <observe> and </observe> tags.
Example:
<observe>
Bar chart showing GDP of 5 countries. X-axis shows country names, Y-axis shows GDP in trillions of USD.
Data values: USA appears highest at around 25, China second at around 20, followed by India, UK, and France.
</observe>
""".strip(),
),
("user", "Question: {question}"),
]
)
EXTRACT_DATA_PROMPT = ChatPromptTemplate(
[
(
"system",
"""
Based on your observation of the chart, extract the specific data values needed to answer the question.
Instructions:
- Extract only the data relevant to the question
- Be precise with values (read carefully from the chart)
- Include labels/categories with each value
- Use appropriate units
## Output Format ##
Provide extracted data inside <extract> and </extract> tags.
Format: Label1: Value1, Label2: Value2, ...
Example:
<extract>
USA: 25, China: 20, India: 15, UK: 10, France: 8
</extract>
""".strip(),
),
(
"user",
"""Observation: {observation}
Question: {question}
Please extract the relevant data values.""",
),
]
)
CALCULATE_ANSWER_PROMPT = ChatPromptTemplate(
[
(
"system",
"""
Using the extracted data, perform any necessary calculations to answer the question.
Instructions:
- Show your calculation steps clearly
- Use correct mathematical operations
- Pay attention to the question (average, sum, difference, maximum, etc.)
- Provide a precise numerical answer if applicable
- Keep the answer concise (typically 1-10 words)
## Output Format ##
Show calculation inside <calculate> and </calculate> tags (if needed).
Provide final answer inside <answer> and </answer> tags.
Example:
<calculate>
Average = (25 + 20 + 15 + 10 + 8) / 5 = 78 / 5 = 15.6
</calculate>
<answer>
15.6
</answer>
""".strip(),
),
(
"user",
"""Extracted Data: {extracted_data}
Question: {question}
Please calculate and provide the answer.""",
),
]
)
CHECK_ANSWER_PROMPT = ChatPromptTemplate(
[
(
"system",
"""
You are a chart analysis expert with strong attention to detail.
Review the answer for potential mistakes.
Common mistakes to check:
- Incorrect data extraction from chart (misread values)
- Arithmetic errors in calculations
- Misunderstanding the question type (average vs. sum vs. difference)
- Wrong number of data points counted
- Incorrect units or scale interpretation
- Off-by-one errors
## Chart Information ##
Observation: {observation}
Extracted Data: {extracted_data}
## Output Format ##
If any mistakes are found, list each error clearly.
After listing mistakes (if any), conclude with **ONE** of the following exact phrases in all caps:
- If mistakes are found: `THE ANSWER IS INCORRECT.`
- If no mistakes are found: `THE ANSWER IS CORRECT.`
DO NOT write the corrected answer in this response. You only need to report mistakes.
""".strip(),
),
(
"user",
"""Question: {question}
Current Answer: {answer}
Calculation shown:
{calculation}
Please review this answer for correctness.""",
),
]
)
REFINE_ANSWER_PROMPT = ChatPromptTemplate(
[
(
"system",
"""
You are a chart analysis agent.
The previous answer had errors. Based on the feedback, provide a corrected answer.
Instructions:
- Re-examine the chart observation carefully
- Correct any data extraction errors by re-extracting if needed
- Fix calculation mistakes
- Address all points mentioned in the feedback
## Chart Observation ##
{observation}
## Output Format ##
If you need to re-extract data, provide it inside <extract> and </extract> tags.
Show corrected calculation inside <calculate> and </calculate> tags.
Provide corrected answer inside <answer> and </answer> tags.
""".strip(),
),
(
"user",
"""Question: {question}
## Previous Attempt ##
Extracted Data: {extracted_data}
Calculation: {calculation}
Answer: {answer}
## Feedback ##
{feedback}
Please provide the corrected answer.""",
),
]
)
+215
View File
@@ -0,0 +1,215 @@
# Copyright (c) Microsoft. All rights reserved.
"""Training helper for ChartQA modeled VERL workflow.
Example usage:
```bash
python train_chartqa_agent.py debug --n-runners 2
```
or:
```bash
AGL_MANAGED_STORE=0 python train_chartqa_agent.py qwen --external-store-address http://localhost:9999
```
Make sure to run `python prepare_data.py` so the parquet files referenced here exist.
"""
from __future__ import annotations
import argparse
import os
import uuid
from copy import deepcopy
from datetime import datetime
from typing import Any, Dict, Optional, cast
import env_var as chartqa_env_var
import pandas as pd
from chartqa_agent import ChartQAAgent
import agentlightning as agl
from agentlightning.env_var import LightningEnvVar, resolve_bool_env_var
RL_CONFIG: Dict[str, Any] = {
"algorithm": {"adv_estimator": "grpo", "use_kl_in_reward": False},
"data": {
"image_base_dir": chartqa_env_var.CHARTQA_IMAGES_DIR,
"train_batch_size": 32,
"max_prompt_length": 4096,
"max_response_length": 1024,
"truncation": "error",
},
"actor_rollout_ref": {
"rollout": {
"tensor_model_parallel_size": 1,
"n": 4,
"log_prob_micro_batch_size_per_gpu": 1,
"name": "vllm",
"gpu_memory_utilization": 0.8,
"enable_prefix_caching": True,
"engine_kwargs": {"vllm": {"allowed_local_media_path": chartqa_env_var.CHARTQA_IMAGES_DIR}},
},
"actor": {
"ppo_mini_batch_size": 32,
"ppo_micro_batch_size_per_gpu": 4,
"optim": {"lr": 1e-6},
"use_kl_loss": False,
"kl_loss_coef": 0.0,
"entropy_coeff": 0,
"clip_ratio_low": 0.2,
"clip_ratio_high": 0.3,
"fsdp_config": {"param_offload": True, "optimizer_offload": True},
},
"ref": {"log_prob_micro_batch_size_per_gpu": 1, "fsdp_config": {"param_offload": True}},
"model": {
"path": "Qwen/Qwen2-VL-2B-Instruct",
"use_remove_padding": True,
"enable_gradient_checkpointing": True,
},
},
"trainer": {
"n_gpus_per_node": 1,
"val_before_train": False,
"critic_warmup": 0,
"logger": ["console", "wandb"],
"project_name": "AgentLightning",
"experiment_name": "chartqa",
"nnodes": 1,
},
}
def config_ci() -> Dict[str, Any]:
"""Return a CI-friendly RL config for ChartQA."""
# For CI testing, we need to set the experiment name and project name so that
# they are available to subsequent steps.
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
random_suffix = uuid.uuid4().hex[:8]
EXPERIMENT_NAME = f"chartqa_ci_{timestamp}_{random_suffix}"
PROJECT_NAME = "AgentLightningCI"
github_output = os.getenv("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a") as f:
f.write(f"project_name={PROJECT_NAME}\n")
f.write(f"run_name={EXPERIMENT_NAME}\n")
config = deepcopy(RL_CONFIG)
config["data"]["train_batch_size"] = 16
config["trainer"]["n_gpus_per_node"] = 1
config["trainer"]["total_training_steps"] = 4
config["trainer"]["val_before_train"] = True
config["trainer"]["test_freq"] = 2
config["trainer"]["experiment_name"] = EXPERIMENT_NAME
config["trainer"]["project_name"] = PROJECT_NAME
return config
def config_debug() -> Dict[str, Any]:
"""Return a short debugging config for smoke testing ChartQA training."""
config = deepcopy(RL_CONFIG)
config["actor_rollout_ref"]["rollout"]["gpu_memory_utilization"] = 0.5
config["trainer"]["total_training_steps"] = 10
config["trainer"]["test_freq"] = 2
return config
def config_qwen() -> Dict[str, Any]:
"""Return a Qwen-focused config with validation before each epoch."""
config = deepcopy(RL_CONFIG)
config["trainer"]["val_before_train"] = True
config["trainer"]["n_gpus_per_node"] = 2
config["trainer"]["total_epochs"] = 2
config["trainer"]["test_freq"] = 32
return config
def train(
config: Dict[str, Any],
train_data: agl.Dataset[Any],
val_data: agl.Dataset[Any],
external_store_address: str,
n_runners: int,
debug: bool,
) -> None:
"""Run VERL training for ChartQA.
Args:
config: VERL configuration produced by one of the helpers above.
train_data: Training dataset of ChartQA samples.
val_data: Validation dataset for periodic evaluation.
external_store_address: Optional address of an existing LightningStore to reuse.
n_runners: Number of runners passed to [`Trainer.fit`][agentlightning.Trainer.fit].
debug: Enables verbose logging tied to `--debug`.
"""
agl.setup_logging(level="DEBUG" if debug else "INFO", apply_to=["agentlightning", __name__])
agent = ChartQAAgent()
algorithm = agl.VERL(config)
if external_store_address:
store: Optional[agl.LightningStore] = agl.LightningStoreClient(external_store_address)
else:
store = None
trainer = agl.Trainer(
n_runners=n_runners,
algorithm=algorithm,
store=store,
)
trainer.fit(agent, train_dataset=train_data, val_dataset=val_data) # type: ignore
def main():
"""Parse CLI arguments and kick off ChartQA training."""
agl.setup_logging(apply_to=["chartqa_agent"])
parser = argparse.ArgumentParser(description="Train ChartQA agent")
parser.add_argument("config", choices=["debug", "qwen", "ci"], help="Training configuration")
parser.add_argument("--n-runners", type=int, default=10, help="Number of runners for Trainer")
parser.add_argument(
"--external-store-address",
type=str,
default=None,
help="Connect to an external store instead of creating a new one in memory (e.g., http://localhost:4747)",
)
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
args = parser.parse_args()
if args.external_store_address:
print(f"Connecting to external store at: {args.external_store_address}")
if resolve_bool_env_var(LightningEnvVar.AGL_MANAGED_STORE, fallback=True):
raise ValueError(
"When using an external store, please set the environment variable AGL_MANAGED_STORE=0. "
"Otherwise the trainer will still try to manage the store lifecycle for you!"
)
CONFIGS = {
"debug": config_debug,
"qwen": config_qwen,
"ci": config_ci,
}
train_data_path = os.path.join(chartqa_env_var.CHARTQA_DATA_DIR, "train_chartqa.parquet")
val_data_path = os.path.join(chartqa_env_var.CHARTQA_DATA_DIR, "test_chartqa.parquet")
train_data = pd.read_parquet(train_data_path).to_dict(orient="records") # type: ignore
if args.config in ["debug", "ci"]:
val_data = pd.read_parquet(val_data_path).sample(n=100, random_state=42).to_dict(orient="records") # type: ignore
else:
val_data = pd.read_parquet(val_data_path).to_dict(orient="records") # type: ignore
train(
config=CONFIGS[args.config](),
train_data=cast(agl.Dataset[Any], train_data),
val_data=cast(agl.Dataset[Any], val_data),
external_store_address=args.external_store_address,
n_runners=args.n_runners,
debug=args.debug,
)
if __name__ == "__main__":
main()
+76 -49
View File
@@ -1,46 +1,54 @@
# Training Claude Code with Agent-lightning
This example demonstrates how to train a Claude Code agent with Agent-lightning. **The example is still under development.**
[![claude-code CI status](https://github.com/microsoft/agent-lightning/actions/workflows/examples-claude-code.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/examples-claude-code.yml)
It wraps Claude Code as the agent to:
This example shows how to wrap Anthropic's Claude Code experience with Agent-lightning instrumentation to solve SWE-bench tasks, collect spans/logs, and optionally convert those traces into HuggingFace datasets.
1. collect traces from agent execution on coding tasks;
2. train a hosted LLM with the traces ***🔨 Under development***
**NOTE:** This example only shows how to integrate Claude Code as an agent in Agent-lightning. The training part is still under development and welcoming contributions!
## Overview
`claude_code_agent.py` spins up a Lightning Store, an LLM proxy, and the Claude Code controller. Each SWE-bench instance is executed inside the official container image so you can either prompt-tune against Anthropic's hosted models or point Claude Code at a self-hosted OpenAI-compatible backend such as vLLM. When a backend surfaces token IDs/logprobs (e.g., vLLM), the traces are turned into triplets that downstream fine-tuning pipelines can consume.
## Requirements
1. Install agentlightning following [installation instructions](https://microsoft.github.io/agent-lightning/stable/tutorials/installation/);
2. `(uv) pip install swebench` for evaluation.
First, install Agent-lightning following the [installation guide](https://microsoft.github.io/agent-lightning/stable/tutorials/installation/). Then install the SWE-bench harness plus utilities used by this example:
```bash
(uv) pip install swebench transformers datasets python-dotenv
```
Docker must be available because each SWE-bench instance is executed in a container via `swebench_utils`.
Finally, set API credentials depending on backend:
- `ANTHROPIC_API_KEY` for the official Claude Code path.
- `OPENAI_API_KEY` (or another OpenAI-compatible key) for the `openai` backend.
- A running OpenAI-compatible server (e.g., vLLM) when using the `vllm` backend.
## Dataset
We provide a small dataset `swebench_samples.jsonl` which is a subset of [SWE-bench](https://huggingface.co/datasets/SWE-bench/SWE-bench) for sanity check.
The instruction to prepare the full dataset is still underway.
`swebench_samples.jsonl` contains a handful of SWE-bench issues for smoke testing. For full-scale benchmarks load `princeton-nlp/SWE-bench` via `load_swebench_dataset` or point `--dataset-path` to your own JSONL file.
## Included Files
| Filename | Description |
|--------------------------------|-------------|
| `cc_agent.py` | Main entry point for running Claude Code agent on coding tasks with trace collection capabilities |
| `claude_code_controller.py` | Controller implementation for managing Claude Code agent interactions and execution |
| `custom_adapter.py` | Custom adapter for integrating with Claude Code's interface and communication protocols |
| `custom_callbacks.py` | Callback handlers for customizing agent behavior and responses during execution |
| `handle_hook.template.sh` | Template script for handling hooks during agent execution |
| `settings.template.json` | Template configuration file with default settings for Claude Code agent |
| `swe_debug.jsonl` | Debug dataset containing a subset of SWE-bench samples for testing and verification |
| `swebench_utils/` | Utility module with helper functions for SWE-bench dataset containerized exeuction and evaluation |
| File/Directory | Description |
|----------------|-------------|
| `claude_code_agent.py` | CLI entry point that launches the Lightning store, LLM proxy, and Claude Code agent |
| `claude_code_controller.py` | Manages the SWE-bench Docker runtime and translates model outputs into git patches |
| `extended_adapter.py` | Adapter that converts LLM proxy spans into triplets with token IDs, logprobs, and chat history |
| `swebench_samples.jsonl` | Mini SWE-bench subset for quick validation |
| `swebench_utils/` | Utilities for running/evaluating SWE-bench instances inside containers |
| `templates/handle_hook.template.sh` | Helper script injected into containers for hook handling |
| `templates/settings.template.json` | Base configuration consumed by Claude Code CLI |
## Trace collection
## Running the Example
We support running Claude Code via two ways:
All commands are issued from `examples/claude_code`. Inspect the module-level docstring in `claude_code_agent.py` for the full CLI reference.
- Hosted LLM servers (i.e., vLLM), useful for fine-tuning the LLM;
- Official Claude Code (i.e., via Anthropic API), useful for prompt tuning.
### Hosted vLLM (open-source models)
### From Hosted LLM server
1. Prepare an OpenAI-compatible server:
First, launch your model behind an OpenAI-compatible endpoint, for example:
```bash
vllm serve Qwen/Qwen3-Coder-30B-A3B-Instruct \
@@ -49,37 +57,56 @@ vllm serve Qwen/Qwen3-Coder-30B-A3B-Instruct \
--tool-call-parser qwen3_coder
```
2. Sanity check:
Run the Agent-lightning harness and point it at the server:
```bash
# Suppose the vllm server is running at localhost:8000
python cc_agent \
--model_name_or_path Qwen/Qwen3-Coder-30B-A3B-Instruct \
--server_address http://localhost:8000/v1 \
--dataset_path swe_debug.jsonl \
--max_step 32 \
--output_dir data_debug
python claude_code_agent.py vllm \
--backend-model-high Qwen/Qwen3-Coder-30B-A3B-Instruct \
--backend-model-low Qwen/Qwen3-Coder-30B-A3B-Instruct \
--frontend-model-high claude-sonnet-4-5-20250929 \
--frontend-model-low claude-haiku-4-5-20251001 \
--base-url http://localhost:8000/v1 \
--dataset-path swebench_samples.jsonl \
--output-dir data_debug \
--max-turns 5 \
--limit 2
```
The above commands will generate a `data_debug` dir, which contains two targets: (1) a Huggingface Dataset named `dataset-<instance_id>` and (2) a trace file named `stream_<instance_id>.jsonl`, where `instance_id` is a unique key of the SWE-bench samples.
The dataset showcases the versatile customization capability of agent-lightning. In particular, we support extracting **prompt/response ids**, **logprobs** from the vllm server.
The trace file is the conversation logs for claude code to tackle the SWE-bench instance.
The backend model names must match what the server exposes. Because this mode surfaces token IDs/logprobs, the script saves both raw span logs and HuggingFace datasets per instance.
In addition, there will be a `logs` dir, which is the output of the docker container executing agent calls.
### Official Claude Code (Anthropic API)
### From official Claude Code
1. Prepare ANTHROPIC_API_KEY
```bash
export ANTHROPIC_API_KEY=sk-<your private key>
export ANTHROPIC_API_KEY=sk-...
python claude_code_agent.py anthropic \
--dataset-path swebench_samples.jsonl \
--output-dir data_anthropic \
--frontend-model-high claude-sonnet-4-5-20250929 \
--frontend-model-low claude-haiku-4-5-20251001
```
2. Sanity check
Backend model flags are optional here because the Anthropic API strings match the frontend names. This path is ideal for validating prompts against the hosted experience (trace outputs do not contain token IDs or logprobs).
### OpenAI-Compatible Providers
```bash
cd examples/cc
python cc_agent \
--official \
--dataset_path swe_debug.jsonl \
--max_step 32 \
--output_dir data_debug
export OPENAI_API_KEY=sk-...
python claude_code_agent.py openai \
--backend-model-high gpt-4.1 \
--backend-model-low gpt-4o-mini \
--dataset-path swebench_samples.jsonl \
--output-dir data_openai
```
As the underlying model is provided by Anthropic, we cannot obtain prompt/response ids and logprobs. However, we can still obtain a trace file named `<instance_id>.json` under `data_debug`.
Use this mode whenever Claude Code should talk to Azure OpenAI, OpenAI, or another compatible provider. `--base-url` is optional—pass it if your endpoint differs from the public OpenAI URL.
Adjust `--max-turns`, `--cooldown-seconds`, and `--limit` to control runtime and rate limits regardless of backend.
## Outputs and Trace Collection
- `output_dir/stream_<instance_id>.json` contains the complete span stream captured from the Lightning Store for each rollout.
- When running with `backend_type=vllm`, `output_dir/dataset-<instance_id>/` stores a HuggingFace dataset with token IDs, logprobs, prompts, and metadata produced by `ExtendedLlmProxyTraceToTriplet`.
- `logs/<instance_id>/` is created by the SWE-bench runtime and mirrors the console output from the container.
- Return values from the agent are also evaluated via `swebench_utils.evaluation.evaluate`, so `data_debug` (or your chosen folder) will contain evaluation reports alongside traces.
Use these artifacts to fine-tune models, debug Claude Code behavior, or replay rollouts in downstream Agent-lightning workflows.
+46 -8
View File
@@ -1,16 +1,54 @@
# Copyright (c) Microsoft. All rights reserved.
"""Main module for the Claude Code Agent implementation.
"""Instrumented driver for running Claude Code on SWE-bench with Agent-lightning.
This module provides the core functionality for running Claude Code agent experiments
on SWE-bench datasets. It includes the ClaudeCodeAgent class that implements the agent logic,
functions for loading datasets, and asynchronous execution functions for running experiments.
This script wires together the Lightning Store, LLM proxy, and Claude Code controller so
that every SWE-bench instance is executed inside the official Claude container while
capturing full Agent-lightning traces. It supports three backend modes:
Key components:
- `vllm`: wrap an OpenAI-compatible endpoint (e.g., vLLM) for hosted OSS models while
collecting prompt/response token ids and logprobs.
- `anthropic`: call the official Claude Code API via `ANTHROPIC_API_KEY` for prompt
tuning. Backend model defaults to the provided frontend names.
- `openai`: route through any OpenAI-compatible provider using `OPENAI_API_KEY`.
- Dataset loading utilities
- ClaudeCodeAgent: Main agent implementation that handles rollout logic
- Asynchronous execution functions for dry runs and full datasets
Typical usage: hosted vLLM (requires model paths and --base-url)
```bash
# Run vLLM in background
vllm serve Qwen/Qwen3-Coder-30B-A3B-Instruct \
--max-model-len 131072 \
--enable-auto-tool-choice \
--tool-call-parser qwen3_coder \
--port 45993 &
python claude_code_agent.py vllm \
--backend-model-high Qwen/Qwen3-Coder-30B-A3B-Instruct \
--backend-model-low Qwen/Qwen3-Coder-30B-A3B-Instruct \
--base-url http://localhost:45993/v1 \
--dataset-path swebench_samples.jsonl \
```
Official Claude Code via Anthropic:
```bash
export ANTHROPIC_API_KEY=sk-...
python claude_code_agent.py anthropic \
--dataset-path swebench_samples.jsonl \
--output-dir data_anthropic
```
Any OpenAI-compatible backend:
```bash
export OPENAI_API_KEY=sk-...
python claude_code_agent.py openai \
--backend-model-high gpt-5.1-codex-mini \
--backend-model-low gpt-4.1-mini \
--dataset-path swebench_samples.jsonl
```
Use `--debug` to enable debug loggings.
"""
import asyncio
+3
View File
@@ -1,5 +1,7 @@
# Minimal Component Showcase
[![minimal CI status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-unit.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/badge-unit.yml)
`examples/minimal` provides bite-sized programs that demonstrate how individual Agent-lightning building blocks behave in isolation.
Each module have been documented with its own CLI usage in the module-level docstring. Use this directory as a reference when wiring the same pieces into a larger system.
@@ -9,6 +11,7 @@ Each module have been documented with its own CLI usage in the module-level docs
| Component | Demonstrated In | Highlights |
| --- | --- | --- |
| LightningStore + OTLP ingestion | `write_traces.py` | Shows how `OtelTracer` and `AgentOpsTracer` open rollouts, emit spans, and optionally forward them to a remote store client. |
| MultiMetrics backend | `write_metrics.py` | Emits counters/histograms through `ConsoleMetricsBackend` and `PrometheusMetricsBackend` simultaneously, exposing `/metrics` for scraping. |
| LLM proxying | `llm_proxy.py` | Guards either OpenAI or a local vLLM deployment with `LLMProxy`, proving how requests are routed through `/rollout/<id>/attempt/<id>` namespaces and captured in the store. |
| vLLM lifecycle | `vllm_server.py` | Minimal context manager that shells out to `vllm serve`, monitors readiness, and tears down the process safely. |

Some files were not shown because too many files have changed in this diff Show More