Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb6f1594a8 | |||
| af28550701 | |||
| 79df678ac1 | |||
| 05cc455cb1 | |||
| 93f2177639 | |||
| a6078caa6c | |||
| f1a8072546 | |||
| 60f9955606 | |||
| 1d199b21c7 | |||
| 5f62ecb6f4 | |||
| 1948c2ba6d | |||
| 1e36e660b1 | |||
| 267b9936bb | |||
| 14714ded2b | |||
| c3f5cc7a39 | |||
| ee0fffd3a2 | |||
| 94d1cd780e | |||
| bbd5c2a30a | |||
| c6f4e6c283 | |||
| 8c504518bb | |||
| 337cce7fdc | |||
| 42c63d7a01 | |||
| 5ecd23792d | |||
| ad89e173e1 | |||
| feebaec24c | |||
| 4adf4e3ea4 | |||
| 0294eb5d32 | |||
| f9fe772e10 | |||
| 9f8a25ffdc | |||
| 3082ac0ee0 | |||
| 56e5c7ce62 | |||
| 21892cc6d3 | |||
| 34811cb454 | |||
| 003b8c6f83 | |||
| 63b6d42669 |
@@ -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 });
|
||||
@@ -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 });
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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!"
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ verl_old
|
||||
meta-llama/**
|
||||
debug/*.png
|
||||
requirements-freeze*.txt
|
||||
/playground
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."),
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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:]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Put contrib-related gitignore files here.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Put code owner definitions here.
|
||||
|
||||
# Recipes
|
||||
recipes/search_r1 @SiyunZhao @JiahangXu
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 repo’s 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.
|
||||
|
||||
|
||||
@@ -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 rollout’s 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 rollout’s transitions as *callbacks* on attempt state changes, plus queue/cancel paths.
|
||||
|
||||
## Spans
|
||||
|
||||
|
||||
@@ -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__
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
## Emitter
|
||||
|
||||
::: agentlightning.operation
|
||||
|
||||
::: agentlightning.emit_annotation
|
||||
|
||||
::: agentlightning.emit_reward
|
||||
|
||||
+26
-5
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
|
||||
## Collections and Collection Implementations
|
||||
|
||||
::: agentlightning.store.collection.AtomicMode
|
||||
|
||||
::: agentlightning.store.collection.AtomicLabels
|
||||
|
||||
::: agentlightning.store.collection.Collection
|
||||
|
||||
::: agentlightning.store.collection.Queue
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
::: agentlightning.Rollout
|
||||
|
||||
::: agentlightning.EnqueueRolloutRequest
|
||||
|
||||
::: agentlightning.Attempt
|
||||
|
||||
::: agentlightning.AttemptedRollout
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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. | [](https://github.com/microsoft/agent-lightning/actions/workflows/examples-apo.yml) |
|
||||
| [azure](./azure) | Supervised fine-tuning with Azure OpenAI. | [](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. | [](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. | [](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. | [](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. | [](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. | [](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. | [](https://github.com/microsoft/agent-lightning/actions/workflows/examples-spider.yml) |
|
||||
| [tinker](./tinker) | Reinforcement learning with Tinker as the backend training service. | [](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. | [](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` | [](https://github.com/microsoft/agent-lightning/actions/workflows/examples-apo.yml) |
|
||||
| `examples-azure.yml` | [](https://github.com/microsoft/agent-lightning/actions/workflows/examples-azure.yml) |
|
||||
| `examples-calc-x.yml` | [](https://github.com/microsoft/agent-lightning/actions/workflows/examples-calc-x.yml) |
|
||||
| `examples-chartqa.yml` | [](https://github.com/microsoft/agent-lightning/actions/workflows/examples-chartqa.yml) |
|
||||
| `examples-claude-code.yml` | [](https://github.com/microsoft/agent-lightning/actions/workflows/examples-claude-code.yml) |
|
||||
| `examples-compat.yml` | [](https://github.com/microsoft/agent-lightning/actions/workflows/examples-compat.yml) |
|
||||
| `examples-rag.yml` | [](https://github.com/microsoft/agent-lightning/actions/workflows/examples-rag.yml) |
|
||||
| `examples-spider.yml` | [](https://github.com/microsoft/agent-lightning/actions/workflows/examples-spider.yml) |
|
||||
| `examples-tinker.yml` | [](https://github.com/microsoft/agent-lightning/actions/workflows/examples-tinker.yml) |
|
||||
| `examples-unsloth.yml` | [](https://github.com/microsoft/agent-lightning/actions/workflows/examples-unsloth.yml) |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Supervised Fine-tuning with Azure OpenAI
|
||||
|
||||
[](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.**
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# ChartQA Example
|
||||
|
||||
[](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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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.""",
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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.**
|
||||
[](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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Minimal Component Showcase
|
||||
|
||||
[](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
Reference in New Issue
Block a user