Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d371881198 | |||
| 86e6eab521 | |||
| 9877972e60 | |||
| 475c2adb91 | |||
| bffc7013f9 | |||
| 4cf8fb94e7 | |||
| ab185a5c5a | |||
| d581cbcd63 | |||
| 3459caa1de | |||
| f3fd58e72a | |||
| b3cb5e1337 | |||
| 3761c0f54c | |||
| d4334182be | |||
| 57c3c0525e | |||
| e356593f73 | |||
| 0e033831d5 | |||
| 0d721228d5 | |||
| e49b75b7d8 | |||
| eab691b1a1 | |||
| fd6494873d | |||
| 6cbfc1fee0 | |||
| b986ae132a | |||
| f24a47969e | |||
| a0bc1827d9 | |||
| f2869cea30 | |||
| 77cf447717 | |||
| 790ed3efb3 | |||
| 5ae7933d41 | |||
| 2ab977ed18 | |||
| 1eae9a34f0 | |||
| 4e7748b059 | |||
| 582f67cade | |||
| 9e23ba6b50 | |||
| 3f8a3ac0f1 | |||
| e0b55ab057 | |||
| 421f2773c7 | |||
| f717f9982f | |||
| 44dbfde0b4 | |||
| 713511902d | |||
| 80531c9c28 | |||
| 37daf2104f | |||
| 9afdd4570c | |||
| 55fbe66fe7 | |||
| 3794c97c1e | |||
| 848623766d | |||
| 4cd09ec900 | |||
| 3ed5e1e5b5 | |||
| 3f372ff7b3 | |||
| c453c41fd2 | |||
| 5c8ac61af6 | |||
| a02e1b91d9 | |||
| 496e793f0b | |||
| 80d306ff54 | |||
| 5f67bfe137 | |||
| f8c45b6ca8 | |||
| 01955aead7 | |||
| 0a9e3d75f2 | |||
| a3b2db18fa | |||
| 268bd77ce6 | |||
| ab6ea3c131 | |||
| d16538da96 | |||
| 8ce40a0410 | |||
| a9c7dbef22 | |||
| e69d24f4a8 | |||
| 955a0cc9a3 | |||
| 3966db6d2a | |||
| 584600d72e | |||
| 955524658d | |||
| 4d5e133a06 | |||
| df2a159b00 | |||
| 675fc86727 | |||
| c16b3a21b6 | |||
| aab976558b | |||
| 91c85aef7e | |||
| a1c36b55a0 | |||
| 6700878f64 | |||
| 56fa8d6881 | |||
| b0f28423b2 | |||
| e28fb8cb6b | |||
| fae0fba3d7 | |||
| 0decbabfbe | |||
| af7a6aa2cc | |||
| ae4e992771 | |||
| 8abe85ad91 | |||
| 22454adedb | |||
| 483c518d74 | |||
| 948506f3b6 | |||
| 34437dd6f5 | |||
| 951fa685b5 | |||
| 2b12e29f32 | |||
| 0e04363f4c | |||
| e91187b491 | |||
| c4b829dbe7 | |||
| 5c274703fe | |||
| 55284f8394 | |||
| 895bffc5b6 | |||
| 8e06fe6902 | |||
| 7d8dccd2b0 | |||
| 89a887d835 | |||
| d31090e9ee | |||
| c6298a96fd | |||
| fcb2a0811e | |||
| bdf6a8f223 | |||
| 8c673c241e | |||
| b7d2d6d6cb | |||
| 46a08d7272 | |||
| cca9e9d62f | |||
| 8aeb0ec1ba | |||
| 65ba916743 | |||
| d35a33dc14 | |||
| 418691e5a2 | |||
| 8b33ddc028 |
@@ -0,0 +1,14 @@
|
||||
.venv
|
||||
**/.venv
|
||||
__pycache__
|
||||
.git
|
||||
.gitignore
|
||||
**/node_modules
|
||||
dist
|
||||
build
|
||||
.env
|
||||
docker
|
||||
.pytest_cache
|
||||
.vscode
|
||||
**/*.log
|
||||
examples/**/data
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Backport Merged Pull Request
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
# NOTE:
|
||||
# Microsoft requires rotating BOT_PAT every 3 months.
|
||||
# Log onto agent-lightning-bot account and rotate the PAT if needed.
|
||||
|
||||
jobs:
|
||||
backport:
|
||||
name: Backport pull request
|
||||
runs-on: ubuntu-latest
|
||||
# Don't run on closed unmerged pull requests
|
||||
if: github.event.pull_request.merged
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create backport pull requests
|
||||
uses: korthout/backport-action@v3
|
||||
with:
|
||||
branch_name: 'backport/${pull_number}/${target_branch}'
|
||||
label_pattern: ^(stable/[^ ]+)$
|
||||
github_token: ${{ secrets.BOT_PAT }}
|
||||
add_labels: backport
|
||||
add_author_as_assignee: true
|
||||
git_committer_name: agent-lightning-bot
|
||||
# This email address is not monitored.
|
||||
git_committer_email: agl.msft@outlook.com
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Badge - APO
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Examples - APO
|
||||
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-apo.yml', label: 'apo', variants: ['legacy', 'stable'] },
|
||||
];
|
||||
await badgeAggregation({ github, context, core, dependencies });
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Badge - Azure
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Examples - Azure
|
||||
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-azure.yml', label: 'azure', variants: ['stable'] },
|
||||
];
|
||||
await badgeAggregation({ github, context, core, dependencies });
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Badge - Calc-X
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Examples - Calc-X
|
||||
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-calc-x.yml', label: 'calc-x', variants: ['legacy', 'stable'] },
|
||||
];
|
||||
await badgeAggregation({ github, context, core, dependencies });
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Badge - Compatibility
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Examples - Backward Compatibility
|
||||
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-compat.yml', label: 'examples-compat', variants: ['legacy', 'stable'] },
|
||||
];
|
||||
await badgeAggregation({ github, context, core, dependencies });
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Badge - Examples
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Examples - Calc-X
|
||||
- Examples - Spider
|
||||
- Examples - APO
|
||||
- Examples - Unsloth
|
||||
- Examples - Tinker
|
||||
- Examples - Azure
|
||||
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-calc-x.yml', label: 'examples-calc-x.stable', variants: ['stable'] },
|
||||
{ workflow: 'examples-spider.yml', label: 'examples-spider.stable', variants: ['stable'] },
|
||||
{ workflow: 'examples-apo.yml', label: 'examples-apo.stable', variants: ['stable'] },
|
||||
{ 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'] },
|
||||
];
|
||||
await badgeAggregation({ github, context, core, dependencies });
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Badge - Latest
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Examples - Calc-X
|
||||
- Examples - Spider
|
||||
- Examples - APO
|
||||
- Examples - Unsloth
|
||||
- GPU Test
|
||||
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-calc-x.yml', label: 'calc-x.latest', variants: ['latest'] },
|
||||
{ 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: 'tests-full.yml', label: 'tests-full.latest', variants: ['latest'] },
|
||||
];
|
||||
await badgeAggregation({ github, context, core, dependencies });
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Badge - Spider
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Examples - Spider
|
||||
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-spider.yml', label: 'spider', variants: ['stable', 'legacy'] },
|
||||
];
|
||||
await badgeAggregation({ github, context, core, dependencies });
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Badge - Tinker
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Examples - Tinker
|
||||
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-tinker.yml', label: 'tinker', variants: ['stable'] },
|
||||
];
|
||||
await badgeAggregation({ github, context, core, dependencies });
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Badge - Unit Test
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- CPU Test
|
||||
- GPU Test
|
||||
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: 'tests-full.yml', label: 'tests-full', variants: ['legacy', 'stable'] },
|
||||
{ workflow: 'tests.yml', label: 'tests', variants: ['legacy', 'stable', 'Lint', 'documentation', 'JavaScript'] },
|
||||
];
|
||||
await badgeAggregation({ github, context, core, dependencies });
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Badge - Unsloth
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Examples - Unsloth
|
||||
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-unsloth.yml', label: 'examples-unsloth.stable', variants: ['stable'] },
|
||||
];
|
||||
await badgeAggregation({ github, context, core, dependencies });
|
||||
@@ -0,0 +1,19 @@
|
||||
# This workflow is used to benchmark the performance of the project.
|
||||
# It's kept as a placeholder for now.
|
||||
|
||||
name: Benchmark
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
benchmark:
|
||||
name: Benchmark
|
||||
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check GPU status
|
||||
run: nvidia-smi
|
||||
- name: Check disk space
|
||||
run: df -h
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Dashboard
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 5 AM UTC+8
|
||||
- cron: '0 21 * * *'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
push:
|
||||
branches: [ main, stable/**/* ]
|
||||
|
||||
jobs:
|
||||
dashboard:
|
||||
name: Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install JavaScript dependencies
|
||||
run: cd dashboard && npm ci
|
||||
- name: Run Chromatic
|
||||
uses: chromaui/action@v13
|
||||
with:
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
workingDir: dashboard
|
||||
exitZeroOnChanges: false
|
||||
+13
-10
@@ -8,6 +8,10 @@ on:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docs-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
@@ -20,15 +24,14 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
./scripts/setup_stable.sh
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Sync dependencies
|
||||
run: uv sync --frozen --no-default-groups --group dev
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
@@ -51,11 +54,11 @@ jobs:
|
||||
- name: Deploy versioned docs
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
mike deploy --push --update-aliases ${{ steps.version.outputs.version }} stable
|
||||
uv run --locked --no-sync mike deploy --push --update-aliases ${{ steps.version.outputs.version }} stable
|
||||
|
||||
- name: Deploy dev docs
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
mike deploy --push latest
|
||||
uv run --locked --no-sync mike deploy --push latest
|
||||
# Always set stable to default
|
||||
mike set-default --push stable
|
||||
uv run --locked --no-sync mike set-default --push stable
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
name: Examples - APO
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 3 AM UTC+8
|
||||
- cron: '0 19 * * *'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
repository_dispatch:
|
||||
types: [ci-apo, ci-all]
|
||||
|
||||
run-name: >-
|
||||
${{ github.event_name == 'repository_dispatch'
|
||||
&& format(
|
||||
'APO - PR #{0} - {1} - {2}',
|
||||
github.event.client_payload.pull_number,
|
||||
github.event.client_payload.ci_label,
|
||||
github.event.client_payload.correlation_id
|
||||
)
|
||||
|| format('APO - {0}', github.event_name) }}
|
||||
|
||||
jobs:
|
||||
apo:
|
||||
if: >
|
||||
github.event_name != 'repository_dispatch' ||
|
||||
github.event.action == 'ci-apo' ||
|
||||
github.event.action == 'ci-all'
|
||||
name: APO (Python ${{ matrix.python-version }}, ${{ matrix.setup-script }})
|
||||
# This job is run on GitHub hosted runners rather than self-hosted runners because it needs no GPU.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
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:
|
||||
- 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 apo \
|
||||
--group dev --group experiment --group agents --group core-stable
|
||||
if: matrix.setup-script == 'latest'
|
||||
- name: Sync dependencies (stable & legacy)
|
||||
run: |
|
||||
uv sync --frozen --no-default-groups --extra apo \
|
||||
--group dev --group experiment --group agents --group core-${{ 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-apo-${{ 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: APO custom algorithm
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/apo
|
||||
uv run apo_custom_algorithm_trainer.py | tee _ci_apo.log
|
||||
# Check whether the log contains "Best prompt found:"
|
||||
grep "Best prompt found:" _ci_apo.log
|
||||
env:
|
||||
# New versions follow OPENAI_BASE_URL instead of OPENAI_API_BASE
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
- name: APO custom algorithm debugger
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/apo
|
||||
uv run apo_debug.py --mode runner
|
||||
uv run apo_debug.py --mode hook
|
||||
uv run apo_debug.py --mode trainer
|
||||
env:
|
||||
# New versions follow OPENAI_BASE_URL instead of OPENAI_API_BASE
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
|
||||
- name: APO built-in algorithm
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/apo
|
||||
uv run room_selector_apo.py
|
||||
env:
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
if: matrix.setup-script != 'legacy'
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Examples - Azure
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 4 AM UTC+8
|
||||
- cron: '0 20 * * *'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
repository_dispatch:
|
||||
types: [ci-azure, ci-all]
|
||||
|
||||
run-name: >-
|
||||
${{ github.event_name == 'repository_dispatch'
|
||||
&& format(
|
||||
'Azure - PR #{0} - {1} - {2}',
|
||||
github.event.client_payload.pull_number,
|
||||
github.event.client_payload.ci_label,
|
||||
github.event.client_payload.correlation_id
|
||||
)
|
||||
|| format('Azure - {0}', github.event_name) }}
|
||||
|
||||
jobs:
|
||||
azure:
|
||||
if: >
|
||||
github.event_name != 'repository_dispatch' ||
|
||||
github.event.action == 'ci-azure' ||
|
||||
github.event.action == 'ci-all'
|
||||
name: Azure (Python ${{ matrix.python-version }}, ${{ matrix.setup-script }})
|
||||
runs-on: [self-hosted, 1ES.Pool=agl-runner-cpu]
|
||||
timeout-minutes: 400
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- python-version: '3.12'
|
||||
setup-script: 'stable'
|
||||
fail-fast: false
|
||||
steps:
|
||||
- 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 \
|
||||
--group dev --group experiment --group agents --group core-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-azure-${{ matrix.python-version }}-${{ matrix.setup-script }}
|
||||
path: requirements-freeze.txt
|
||||
compression-level: 0
|
||||
|
||||
- name: Azure Login
|
||||
run: |
|
||||
az login --identity
|
||||
shell: bash
|
||||
|
||||
- name: Azure OpenAI Sanity Check
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
cd examples/azure
|
||||
python capital_agent.py
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT_SWEDEN }}
|
||||
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY_SWEDEN }}
|
||||
id: azure_openai_sanity_check
|
||||
|
||||
- name: Azure OpenAI Supervised Fine-tuning
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
cd examples/azure
|
||||
python train_capital_agent.py --n-iterations 2 --cleanup
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT_SWEDEN }}
|
||||
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY_SWEDEN }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_OPENAI_API_VERSION: 2025-04-01-preview
|
||||
AZURE_RESOURCE_GROUP: ${{ secrets.AZURE_RESOURCE_GROUP }}
|
||||
AZURE_RESOURCE_NAME: ${{ secrets.AZURE_RESOURCE_NAME }}
|
||||
id: azure_openai_finetune
|
||||
@@ -0,0 +1,340 @@
|
||||
name: Examples - Calc-X
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 3 AM UTC+8
|
||||
- cron: '0 19 * * *'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
repository_dispatch:
|
||||
types: [ci-calc-x, ci-all]
|
||||
|
||||
run-name: >-
|
||||
${{ github.event_name == 'repository_dispatch'
|
||||
&& format(
|
||||
'Calc-X - PR #{0} - {1} - {2}',
|
||||
github.event.client_payload.pull_number,
|
||||
github.event.client_payload.ci_label,
|
||||
github.event.client_payload.correlation_id
|
||||
)
|
||||
|| format('Calc-X - {0}', github.event_name) }}
|
||||
|
||||
jobs:
|
||||
calc-x-perf:
|
||||
if: >
|
||||
github.event_name != 'repository_dispatch' ||
|
||||
github.event.action == 'ci-calc-x' ||
|
||||
github.event.action == 'ci-all'
|
||||
name: Calc-X Performance (Python ${{ matrix.python-version }}, ${{ matrix.setup-script }})
|
||||
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
|
||||
timeout-minutes: 90
|
||||
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 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 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-calc-x-performance-${{ 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 Calc-X dataset
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/calc_x
|
||||
uv run gdown --fuzzy https://drive.google.com/file/d/1FQMyKLLd6hP9dw9rfZn1EZOWNvKaDsqw/view
|
||||
unzip calc-x-data.zip -d data
|
||||
rm calc-x-data.zip
|
||||
|
||||
- name: Calc-X MCP sanity check
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/calc_x
|
||||
uv run tests/test_mcp_calculator.py
|
||||
env:
|
||||
OPENAI_API_BASE: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
- name: Calc-X sanity check
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/calc_x
|
||||
uv run legacy_calc_agent_debug.py
|
||||
env:
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
|
||||
# Calc-X training suddenly works after running the sanity check.
|
||||
# And it has to be run before Spider training.
|
||||
# The client side used to hang in many of my attempts.
|
||||
# Don't ask why. Don't touch this.
|
||||
- name: Calc-X training
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
cd examples/calc_x
|
||||
../../scripts/restart_ray.sh
|
||||
sleep 5
|
||||
python train_calc_agent.py --val-file data/test_mini.parquet --ci
|
||||
shell: bash
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
id: calc_x_train
|
||||
|
||||
- name: Validate Calc-X training
|
||||
run: |
|
||||
set -ex
|
||||
uv run scripts/validate_example_wandb.py ${{ steps.calc_x_train.outputs.project_name }} ${{ steps.calc_x_train.outputs.run_name }}
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
|
||||
calc-x-variants:
|
||||
if: >
|
||||
github.event_name != 'repository_dispatch' ||
|
||||
github.event.action == 'ci-calc-x' ||
|
||||
github.event.action == 'ci-all'
|
||||
name: Calc-X Variants (Python ${{ matrix.python-version }}, ${{ matrix.setup-script }})
|
||||
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
|
||||
timeout-minutes: 90
|
||||
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 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 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-calc-x-variants-${{ 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 Calc-X dataset
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/calc_x
|
||||
uv run gdown --fuzzy https://drive.google.com/file/d/1FQMyKLLd6hP9dw9rfZn1EZOWNvKaDsqw/view
|
||||
unzip calc-x-data.zip -d data
|
||||
rm calc-x-data.zip
|
||||
|
||||
- name: Calc-X MCP sanity check
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/calc_x
|
||||
uv run tests/test_mcp_calculator.py
|
||||
env:
|
||||
OPENAI_API_BASE: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
- name: Calc-X sanity check
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/calc_x
|
||||
uv run legacy_calc_agent_debug.py
|
||||
env:
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
|
||||
- name: Training with local model
|
||||
run: |
|
||||
set -ex
|
||||
source .venv/bin/activate
|
||||
cd examples/calc_x
|
||||
../../scripts/restart_ray.sh
|
||||
sleep 5
|
||||
hf download Qwen/Qwen2.5-0.5B-Instruct --local-dir data/qwen_model
|
||||
PYTHONUNBUFFERED=1 python train_calc_agent.py --val-file data/test_mini.parquet --ci-fast --model $(realpath data/qwen_model)
|
||||
sleep 10
|
||||
shell: bash
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
id: calc_x_train_local_model
|
||||
|
||||
- name: Validate training with local model
|
||||
run: |
|
||||
set -ex
|
||||
uv run scripts/validate_example_wandb.py ${{ steps.calc_x_train_local_model.outputs.project_name }} ${{ steps.calc_x_train_local_model.outputs.run_name }}
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
|
||||
- name: Training with LLM Proxy
|
||||
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 --llm-proxy
|
||||
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_llm_proxy
|
||||
|
||||
- name: Validate training with LLM Proxy
|
||||
run: |
|
||||
set -ex
|
||||
uv run scripts/validate_example_wandb.py ${{ steps.calc_x_train_llm_proxy.outputs.project_name }} ${{ steps.calc_x_train_llm_proxy.outputs.run_name }}
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
|
||||
- name: Training with external store
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/calc_x
|
||||
../../scripts/restart_ray.sh
|
||||
|
||||
agl store --port 4747 &
|
||||
sleep 5
|
||||
AGL_MANAGED_STORE=0 AGL_CURRENT_ROLE=runner python train_calc_agent.py --external-store-address http://localhost:4747 --val-file data/test_mini.parquet --ci-fast &
|
||||
sleep 5
|
||||
AGL_MANAGED_STORE=0 AGL_CURRENT_ROLE=algorithm python train_calc_agent.py --external-store-address http://localhost:4747 --val-file data/test_mini.parquet --ci-fast
|
||||
|
||||
pkill -f agl && echo "SIGTERM sent to agl" || echo "No agl process found"
|
||||
while pgrep -f agl; do
|
||||
echo "Waiting for agl to finish..."
|
||||
sleep 5
|
||||
done
|
||||
pkill -f train_calc_agent.py && echo "SIGTERM sent to train_calc_agent.py" || echo "No train_calc_agent.py process found"
|
||||
while pgrep -f train_calc_agent.py; do
|
||||
echo "Waiting for train_calc_agent.py to finish..."
|
||||
sleep 5
|
||||
done
|
||||
echo "train_calc_agent.py has finished."
|
||||
shell: bash
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
id: calc_x_train_external_store
|
||||
|
||||
- name: Validate training with external store
|
||||
run: |
|
||||
set -ex
|
||||
uv run scripts/validate_example_wandb.py ${{ steps.calc_x_train_external_store.outputs.project_name }} ${{ steps.calc_x_train_external_store.outputs.run_name }}
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
|
||||
- name: Training with role-based environment variables
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/calc_x
|
||||
../../scripts/restart_ray.sh
|
||||
|
||||
PYTHONUNBUFFERED=1 AGL_SERVER_HOST=127.0.0.1 AGL_SERVER_PORT=5858 AGL_CURRENT_ROLE=runner python train_calc_agent.py --val-file data/test_mini.parquet --ci-fast &
|
||||
sleep 5
|
||||
PYTHONUNBUFFERED=1 AGL_SERVER_HOST=0.0.0.0 AGL_SERVER_PORT=5858 AGL_CURRENT_ROLE=algorithm python train_calc_agent.py --val-file data/test_mini.parquet --ci-fast
|
||||
|
||||
pkill -f train_calc_agent.py && echo "SIGTERM sent to train_calc_agent.py" || echo "No train_calc_agent.py process found"
|
||||
while pgrep -f train_calc_agent.py; do
|
||||
echo "Waiting for train_calc_agent.py to finish..."
|
||||
sleep 5
|
||||
done
|
||||
echo "train_calc_agent.py has finished."
|
||||
shell: bash
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
id: calc_x_train_role_based_env_var
|
||||
|
||||
- name: Validate training with role-based environment variables
|
||||
run: |
|
||||
set -ex
|
||||
uv run scripts/validate_example_wandb.py ${{ steps.calc_x_train_role_based_env_var.outputs.project_name }} ${{ steps.calc_x_train_role_based_env_var.outputs.run_name }}
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
@@ -0,0 +1,151 @@
|
||||
name: Examples - Backward Compatibility
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 6 AM UTC+8
|
||||
- cron: '0 22 * * *'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
repository_dispatch:
|
||||
types: [ci-compat, ci-all]
|
||||
|
||||
run-name: >-
|
||||
${{ github.event_name == 'repository_dispatch'
|
||||
&& format(
|
||||
'Backward Compatibility - PR #{0} - {1} - {2}',
|
||||
github.event.client_payload.pull_number,
|
||||
github.event.client_payload.ci_label,
|
||||
github.event.client_payload.correlation_id
|
||||
)
|
||||
|| format('Backward Compatibility - {0}', github.event_name) }}
|
||||
|
||||
jobs:
|
||||
backward-compatibility:
|
||||
if: >
|
||||
github.event_name != 'repository_dispatch' ||
|
||||
github.event.action == 'ci-compat' ||
|
||||
github.event.action == 'ci-all'
|
||||
name: Backward Compatibility (Python ${{ matrix.python-version }}, ${{ matrix.setup-script }})
|
||||
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- python-version: '3.10'
|
||||
setup-script: 'legacy'
|
||||
- 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: Sync dependencies
|
||||
run: |
|
||||
uv sync --frozen --no-default-groups --extra apo --extra verl \
|
||||
--group dev --group experiment --group agents --group torch-gpu-${{ matrix.setup-script }}
|
||||
- name: Override VERL (stable)
|
||||
run: |
|
||||
uv pip install verl==0.5.0 vllm==0.10.2
|
||||
if: matrix.setup-script == '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-backward-compatibility-${{ 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 Calc-X dataset
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/calc_x
|
||||
uv run gdown --fuzzy https://drive.google.com/file/d/1FQMyKLLd6hP9dw9rfZn1EZOWNvKaDsqw/view
|
||||
unzip calc-x-data.zip -d data
|
||||
rm calc-x-data.zip
|
||||
|
||||
- name: APO example (legacy client-server style)
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/apo
|
||||
uv run legacy_apo_client.py &
|
||||
sleep 3 # Wait for the client to be up
|
||||
uv run legacy_apo_server.py
|
||||
pkill -f legacy_apo_client.py && echo "SIGTERM sent to legacy_apo_client.py" || echo "No legacy_apo_client.py process found"
|
||||
while pgrep -f legacy_apo_client.py; do
|
||||
echo "Waiting for legacy_apo_client.py to finish..."
|
||||
sleep 5
|
||||
done
|
||||
echo "legacy_apo_client.py has finished."
|
||||
sleep 10
|
||||
env:
|
||||
OPENAI_API_BASE: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
|
||||
- name: Calc-X MCP sanity check
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/calc_x
|
||||
uv run tests/test_mcp_calculator.py
|
||||
env:
|
||||
OPENAI_API_BASE: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
- name: Calc-X sanity check
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/calc_x
|
||||
uv run legacy_calc_agent_debug.py
|
||||
env:
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
|
||||
- name: Calc-X training (legacy client-server style)
|
||||
run: |
|
||||
set -ex
|
||||
source .venv/bin/activate
|
||||
cd examples/calc_x
|
||||
../../scripts/restart_ray.sh
|
||||
sleep 5
|
||||
PYTHONUNBUFFERED=1 python legacy_calc_agent.py &
|
||||
bash legacy_train.sh
|
||||
pkill -f legacy_calc_agent.py && echo "SIGTERM sent to legacy_calc_agent.py" || echo "No legacy_calc_agent.py process found"
|
||||
while pgrep -f legacy_calc_agent.py; do
|
||||
echo "Waiting for legacy_calc_agent.py to finish..."
|
||||
sleep 5
|
||||
done
|
||||
echo "legacy_calc_agent.py has finished."
|
||||
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
|
||||
|
||||
- name: Validate Calc-X training
|
||||
run: |
|
||||
set -ex
|
||||
uv run scripts/validate_example_wandb.py ${{ steps.calc_x_train.outputs.project_name }} ${{ steps.calc_x_train.outputs.run_name }}
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
@@ -0,0 +1,127 @@
|
||||
name: Examples - Spider
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 4 AM UTC+8
|
||||
- cron: '0 20 * * *'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
repository_dispatch:
|
||||
types: [ci-spider, ci-all]
|
||||
|
||||
run-name: >-
|
||||
${{ github.event_name == 'repository_dispatch'
|
||||
&& format(
|
||||
'Spider - PR #{0} - {1} - {2}',
|
||||
github.event.client_payload.pull_number,
|
||||
github.event.client_payload.ci_label,
|
||||
github.event.client_payload.correlation_id
|
||||
)
|
||||
|| format('Spider - {0}', github.event_name) }}
|
||||
|
||||
jobs:
|
||||
spider:
|
||||
if: >
|
||||
github.event_name != 'repository_dispatch' ||
|
||||
github.event.action == 'ci-spider' ||
|
||||
github.event.action == 'ci-all'
|
||||
name: Spider (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 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 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-spider-${{ 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 Spider dataset
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/spider
|
||||
uv run gdown --fuzzy https://drive.google.com/file/d/1oi9J1jZP9TyM35L85CL3qeGWl2jqlnL6/view
|
||||
unzip -q spider-data.zip -d data
|
||||
rm spider-data.zip
|
||||
|
||||
- name: Spider sanity check
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/spider
|
||||
uv run sql_agent.py
|
||||
env:
|
||||
OPENAI_API_BASE: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
if: success() || failure()
|
||||
|
||||
- name: Spider training
|
||||
run: |
|
||||
set -ex
|
||||
source .venv/bin/activate
|
||||
cd examples/spider
|
||||
../../scripts/restart_ray.sh
|
||||
sleep 5
|
||||
PYTHONUNBUFFERED=1 python train_sql_agent.py fast
|
||||
sleep 10
|
||||
shell: bash
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
id: spider_train
|
||||
|
||||
- name: Validate Spider training
|
||||
run: |
|
||||
set -ex
|
||||
uv run scripts/validate_example_wandb.py ${{ steps.spider_train.outputs.project_name }} ${{ steps.spider_train.outputs.run_name }} --reward-tolerance 5
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
@@ -0,0 +1,160 @@
|
||||
name: Examples - Tinker
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 3 AM UTC+8
|
||||
- cron: '0 19 * * *'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
repository_dispatch:
|
||||
types: [ci-tinker, ci-all]
|
||||
|
||||
run-name: >-
|
||||
${{ github.event_name == 'repository_dispatch'
|
||||
&& format(
|
||||
'Tinker - PR #{0} - {1} - {2}',
|
||||
github.event.client_payload.pull_number,
|
||||
github.event.client_payload.ci_label,
|
||||
github.event.client_payload.correlation_id
|
||||
)
|
||||
|| format('Tinker - {0}', github.event_name) }}
|
||||
|
||||
jobs:
|
||||
tinker:
|
||||
if: >
|
||||
github.event_name != 'repository_dispatch' ||
|
||||
github.event.action == 'ci-tinker' ||
|
||||
github.event.action == 'ci-all'
|
||||
name: Tinker (Python ${{ matrix.python-version }}, ${{ matrix.setup-script }})
|
||||
runs-on: [self-hosted, 1ES.Pool=agl-runner-cpu]
|
||||
timeout-minutes: 150
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- python-version: '3.12'
|
||||
setup-script: 'stable'
|
||||
- python-version: '3.13'
|
||||
setup-script: 'latest'
|
||||
fail-fast: false
|
||||
steps:
|
||||
- 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 \
|
||||
--group dev --group experiment --group agents --group torch-cpu --group core-stable --group tinker
|
||||
- name: Freeze dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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-tinker-${{ matrix.python-version }}-${{ matrix.setup-script }}
|
||||
path: requirements-freeze.txt
|
||||
compression-level: 0
|
||||
|
||||
- name: Tinker LLM sanity check
|
||||
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
|
||||
shell: bash
|
||||
env:
|
||||
TINKER_API_KEY: ${{ secrets.TINKER_API_KEY }}
|
||||
|
||||
- name: Tinker Hello
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/tinker
|
||||
python hello.py oneclick --ci
|
||||
shell: bash
|
||||
env:
|
||||
TINKER_API_KEY: ${{ secrets.TINKER_API_KEY }}
|
||||
|
||||
- name: Tinker Q20 Evaluate (GPT-4.1)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/tinker
|
||||
mkdir -p logs
|
||||
python q20_evaluate.py --ci --model gpt-4.1 --output-file logs/q20_evaluate_gpt-4.1.jsonl
|
||||
shell: bash
|
||||
env:
|
||||
OPENAI_BASE_URL: ${{ secrets.AZURE_OPENAI_ENDPOINT_SWEDEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY_SWEDEN }}
|
||||
CREWAI_DISABLE_TELEMETRY: true
|
||||
TINKER_API_KEY: ${{ secrets.TINKER_API_KEY }}
|
||||
|
||||
- name: Tinker Q20 Evaluate (Qwen3-30B-A3B-Instruct-2507)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/tinker
|
||||
python q20_evaluate.py --ci --model Qwen/Qwen3-30B-A3B-Instruct-2507 --output-file logs/q20_evaluate_qwen3-30b-a3b.jsonl
|
||||
shell: bash
|
||||
env:
|
||||
OPENAI_BASE_URL: ${{ secrets.AZURE_OPENAI_ENDPOINT_SWEDEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY_SWEDEN }}
|
||||
CREWAI_DISABLE_TELEMETRY: true
|
||||
TINKER_API_KEY: ${{ secrets.TINKER_API_KEY }}
|
||||
|
||||
- name: Tinker Q20 Training Dry Run
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/tinker
|
||||
python q20_train.py dryrun --model qwen4b
|
||||
shell: bash
|
||||
env:
|
||||
OPENAI_BASE_URL: ${{ secrets.AZURE_OPENAI_ENDPOINT_SWEDEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY_SWEDEN }}
|
||||
CREWAI_DISABLE_TELEMETRY: true
|
||||
TINKER_API_KEY: ${{ secrets.TINKER_API_KEY }}
|
||||
|
||||
- name: Tinker Q20 Training
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/tinker
|
||||
agl store --port 4747 &
|
||||
sleep 5
|
||||
python q20_train.py runner --n-runners 4 &
|
||||
sleep 5
|
||||
python q20_train.py algo --model qwen4b --ci
|
||||
sleep 5
|
||||
|
||||
pkill -f agl && echo "SIGTERM sent to agl" || echo "No agl process found"
|
||||
while pgrep -f agl; do
|
||||
echo "Waiting for agl to finish..."
|
||||
sleep 5
|
||||
done
|
||||
pkill -f q20_train.py && echo "SIGTERM sent to q20_train.py" || echo "No q20_train.py process found"
|
||||
while pgrep -f q20_train.py; do
|
||||
echo "Waiting for q20_train.py to finish..."
|
||||
sleep 5
|
||||
done
|
||||
echo "q20_train.py has finished."
|
||||
shell: bash
|
||||
env:
|
||||
OPENAI_BASE_URL: ${{ secrets.AZURE_OPENAI_ENDPOINT_SWEDEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY_SWEDEN }}
|
||||
CREWAI_DISABLE_TELEMETRY: true
|
||||
TINKER_API_KEY: ${{ secrets.TINKER_API_KEY }}
|
||||
@@ -0,0 +1,129 @@
|
||||
name: Examples - Unsloth
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 5 AM UTC+8
|
||||
- cron: '0 21 * * *'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
repository_dispatch:
|
||||
types: [ci-unsloth, ci-all]
|
||||
|
||||
run-name: >-
|
||||
${{ github.event_name == 'repository_dispatch'
|
||||
&& format(
|
||||
'Unsloth - PR #{0} - {1} - {2}',
|
||||
github.event.client_payload.pull_number,
|
||||
github.event.client_payload.ci_label,
|
||||
github.event.client_payload.correlation_id
|
||||
)
|
||||
|| format('Unsloth - {0}', github.event_name) }}
|
||||
|
||||
jobs:
|
||||
unsloth:
|
||||
if: >
|
||||
github.event_name != 'repository_dispatch' ||
|
||||
github.event.action == 'ci-unsloth' ||
|
||||
github.event.action == 'ci-all'
|
||||
name: Unsloth (Python ${{ matrix.python-version }}, ${{ matrix.setup-script }})
|
||||
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
# Legacy versions are not supported for Unsloth examples.
|
||||
include:
|
||||
- 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
|
||||
run: |
|
||||
uv sync --frozen --no-default-groups --extra verl \
|
||||
--group dev --group experiment --group trl --group agents --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-unsloth-${{ matrix.python-version }}-${{ matrix.setup-script }}
|
||||
path: requirements-freeze.txt
|
||||
compression-level: 0
|
||||
|
||||
- name: Prepare Unsloth model
|
||||
run: |
|
||||
set -ex
|
||||
cd examples/unsloth
|
||||
rm -rf models
|
||||
uv run hf download unsloth/Qwen3-4B-Instruct-2507 --local-dir models/version_0
|
||||
|
||||
- name: Unsloth SFT example
|
||||
run: |
|
||||
set -ex
|
||||
source .venv/bin/activate
|
||||
cd examples/unsloth
|
||||
|
||||
agl store --port 4747 &
|
||||
sleep 5
|
||||
python sft_rollout_runners.py &
|
||||
sleep 5
|
||||
python sft_algorithm.py
|
||||
|
||||
pkill -f agl && echo "SIGTERM sent to agl" || echo "No agl process found"
|
||||
while pgrep -f agl; do
|
||||
echo "Waiting for agl to finish..."
|
||||
sleep 5
|
||||
done
|
||||
pkill -f sft_rollout_runners.py && echo "SIGTERM sent to sft_rollout_runners.py" || echo "No sft_rollout_runners.py process found"
|
||||
while pgrep -f sft_rollout_runners.py; do
|
||||
echo "Waiting for sft_rollout_runners.py to finish..."
|
||||
sleep 5
|
||||
done
|
||||
echo "sft_rollout_runners.py has finished."
|
||||
sleep 10
|
||||
|
||||
# Check models/version_2 must exist
|
||||
if [ ! -d "models/version_2" ]; then
|
||||
echo "models/version_2 does not exist"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
|
||||
- name: Unsloth SFT example all-in-one
|
||||
run: |
|
||||
set -ex
|
||||
source .venv/bin/activate
|
||||
cd examples/unsloth
|
||||
rm -rf models/version_1 models/version_2
|
||||
|
||||
python sft_allinone.py
|
||||
if [ ! -d "models/version_2" ]; then
|
||||
echo "models/version_2 does not exist"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
@@ -1,330 +0,0 @@
|
||||
name: Examples Test
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 3 AM UTC+8
|
||||
- cron: '0 19 * * *'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
examples:
|
||||
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
matrix:
|
||||
setup: [stable, latest]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Check GPU status
|
||||
run: nvidia-smi
|
||||
- name: Check disk space
|
||||
run: df -h
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create a virtual environment
|
||||
run: python3 -m venv .venv
|
||||
- name: Install dependencies (${{ matrix.setup }})
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
./scripts/setup_${{ matrix.setup }}_gpu.sh
|
||||
- name: Freeze dependencies
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
which python
|
||||
which pip
|
||||
which uvx
|
||||
pip list | tee requirements-freeze.txt
|
||||
- name: Upload dependencies artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dependencies-${{ matrix.setup }}
|
||||
path: requirements-freeze.txt
|
||||
compression-level: 0
|
||||
|
||||
- name: Launch LiteLLM Proxy
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
litellm --config scripts/litellm_ci.yaml --port 12306 &
|
||||
sleep 10 # Wait for the proxy to be up
|
||||
env:
|
||||
AZURE_API_BASE: ${{ secrets.AZURE_API_BASE }}
|
||||
AZURE_API_KEY: ${{ secrets.AZURE_API_KEY }}
|
||||
|
||||
- name: Verify LiteLLM Proxy
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
python scripts/litellm_sanity_check.py
|
||||
env:
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
|
||||
- name: Prepare Unsloth model
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
cd examples/unsloth
|
||||
rm -rf models
|
||||
hf download unsloth/Qwen3-4B-Instruct-2507 --local-dir models/version_0
|
||||
|
||||
- name: Prepare Spider dataset
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
cd examples/spider
|
||||
gdown --fuzzy https://drive.google.com/file/d/1oi9J1jZP9TyM35L85CL3qeGWl2jqlnL6/view
|
||||
unzip -q spider-data.zip -d data
|
||||
rm spider-data.zip
|
||||
- name: Prepare Calc-X dataset
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
cd examples/calc_x
|
||||
gdown --fuzzy https://drive.google.com/file/d/1FQMyKLLd6hP9dw9rfZn1EZOWNvKaDsqw/view
|
||||
unzip calc-x-data.zip -d data
|
||||
rm calc-x-data.zip
|
||||
|
||||
# APO Examples test
|
||||
- name: APO example (legacy)
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
cd examples/apo
|
||||
python legacy_apo_client.py &
|
||||
sleep 3 # Wait for the client to be up
|
||||
python legacy_apo_server.py
|
||||
pkill -f legacy_apo_client.py && echo "SIGTERM sent to legacy_apo_client.py" || echo "No legacy_apo_client.py process found"
|
||||
while pgrep -f legacy_apo_client.py; do
|
||||
echo "Waiting for legacy_apo_client.py to finish..."
|
||||
sleep 5
|
||||
done
|
||||
echo "legacy_apo_client.py has finished."
|
||||
sleep 10
|
||||
env:
|
||||
OPENAI_API_BASE: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
- name: APO example
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
cd examples/apo
|
||||
python apo.py | tee _ci_apo.log
|
||||
# Check whether the log contains "Best prompt found:"
|
||||
grep "Best prompt found:" _ci_apo.log
|
||||
env:
|
||||
# New versions follow OPENAI_BASE_URL instead of OPENAI_API_BASE
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
- name: APO example debug sanity check
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
cd examples/apo
|
||||
python apo_debug.py --mode runner
|
||||
python apo_debug.py --mode trainer
|
||||
env:
|
||||
# New versions follow OPENAI_BASE_URL instead of OPENAI_API_BASE
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
|
||||
- name: APO built-in algorithm
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
cd examples/apo
|
||||
python room_selector_apo.py
|
||||
env:
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
if: success() || failure()
|
||||
|
||||
- name: Spider sanity check
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
cd examples/spider
|
||||
python sql_agent.py --trainer.n-workers 1 --trainer.dev true --trainer.max-tasks 2
|
||||
env:
|
||||
VERL_API_BASE: http://localhost:9999/
|
||||
OPENAI_API_BASE: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
if: success() || failure()
|
||||
- name: Calc-X MCP sanity check
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
cd examples/calc_x
|
||||
python tests/test_mcp_calculator.py
|
||||
env:
|
||||
OPENAI_API_BASE: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
- name: Calc-X sanity check
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
cd examples/calc_x
|
||||
python calc_agent_dev.py
|
||||
env:
|
||||
OPENAI_API_BASE: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
|
||||
# Calc-X training suddenly works after running the sanity check.
|
||||
# And it has to be run before Spider training.
|
||||
# The client side used to hang in many of my attempts.
|
||||
# Don't ask why. Don't touch this.
|
||||
- name: Calc-X training v0.1
|
||||
run: |
|
||||
set -ex
|
||||
source .venv/bin/activate
|
||||
cd examples/calc_x
|
||||
../../scripts/restart_ray.sh
|
||||
sleep 5
|
||||
PYTHONUNBUFFERED=1 python calc_agent.py &
|
||||
bash train_ci.sh
|
||||
pkill -f calc_agent.py && echo "SIGTERM sent to calc_agent.py" || echo "No calc_agent.py process found"
|
||||
while pgrep -f calc_agent.py; do
|
||||
echo "Waiting for calc_agent.py to finish..."
|
||||
sleep 5
|
||||
done
|
||||
echo "calc_agent.py has finished."
|
||||
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
|
||||
if: success() || failure()
|
||||
|
||||
- name: Validate Calc-X training
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
python scripts/validate_example_wandb.py ${{ steps.calc_x_train.outputs.project_name }} ${{ steps.calc_x_train.outputs.run_name }}
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
|
||||
- name: Calc-X training v0.2
|
||||
run: |
|
||||
set -ex
|
||||
source .venv/bin/activate
|
||||
cd examples/calc_x
|
||||
../../scripts/restart_ray.sh
|
||||
sleep 5
|
||||
PYTHONUNBUFFERED=1 python calc_agent_v0_2.py
|
||||
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_v0_2
|
||||
if: success() || failure()
|
||||
|
||||
- name: Calc-X training v0.2 LLM Proxy
|
||||
run: |
|
||||
set -ex
|
||||
source .venv/bin/activate
|
||||
cd examples/calc_x
|
||||
../../scripts/restart_ray.sh
|
||||
sleep 5
|
||||
PYTHONUNBUFFERED=1 python calc_agent_v0_2_llm_proxy.py
|
||||
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_v0_2_llm_proxy
|
||||
if: success() || failure()
|
||||
|
||||
- name: Spider training
|
||||
run: |
|
||||
set -ex
|
||||
source .venv/bin/activate
|
||||
cd examples/spider
|
||||
../../scripts/restart_ray.sh
|
||||
sleep 5
|
||||
PYTHONUNBUFFERED=1 python sql_agent.py --trainer.n-workers 10 &
|
||||
bash train_ci.sh
|
||||
pkill -f sql_agent.py && echo "SIGTERM sent to sql_agent.py" || echo "No sql_agent.py process found"
|
||||
while pgrep -f sql_agent.py; do
|
||||
echo "Waiting for sql_agent.py to finish..."
|
||||
sleep 5
|
||||
done
|
||||
echo "sql_agent.py has finished."
|
||||
sleep 10
|
||||
shell: bash
|
||||
env:
|
||||
VERL_API_BASE: http://localhost:9991/
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
id: spider_train
|
||||
if: success() || failure()
|
||||
|
||||
- name: Validate Spider training
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
python scripts/validate_example_wandb.py ${{ steps.spider_train.outputs.project_name }} ${{ steps.spider_train.outputs.run_name }}
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
|
||||
# Unsloth Examples test
|
||||
- name: Unsloth SFT example
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
cd examples/unsloth
|
||||
|
||||
agl store --port 4747 &
|
||||
sleep 5
|
||||
python sft_rollout_runners.py &
|
||||
sleep 5
|
||||
python sft_algorithm.py
|
||||
|
||||
pkill -f agl && echo "SIGTERM sent to agl" || echo "No agl process found"
|
||||
while pgrep -f agl; do
|
||||
echo "Waiting for agl to finish..."
|
||||
sleep 5
|
||||
done
|
||||
pkill -f sft_rollout_runners.py && echo "SIGTERM sent to sft_rollout_runners.py" || echo "No sft_rollout_runners.py process found"
|
||||
while pgrep -f sft_rollout_runners.py; do
|
||||
echo "Waiting for sft_rollout_runners.py to finish..."
|
||||
sleep 5
|
||||
done
|
||||
echo "sft_rollout_runners.py has finished."
|
||||
sleep 10
|
||||
|
||||
# Check models/version_2 must exist
|
||||
if [ ! -d "models/version_2" ]; then
|
||||
echo "models/version_2 does not exist"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
if: ${{ (success() || failure()) && matrix.setup == 'latest' }}
|
||||
|
||||
- name: Unsloth SFT example all-in-one
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
cd examples/unsloth
|
||||
rm -rf models/version_1 models/version_2
|
||||
|
||||
python sft_allinone.py
|
||||
if [ ! -d "models/version_2" ]; then
|
||||
echo "models/version_2 does not exist"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
|
||||
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
|
||||
if: matrix.setup == 'latest'
|
||||
|
||||
# Cleanup
|
||||
- name: Cleanup
|
||||
run: ./scripts/cleanup.sh
|
||||
if: success() || failure()
|
||||
@@ -0,0 +1,309 @@
|
||||
name: Issue Comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
# Only run for comments on pull requests AND when the comment starts with "/ci"
|
||||
if: >
|
||||
github.event.issue.pull_request != null &&
|
||||
startsWith(github.event.comment.body, '/ci')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
dispatched: ${{ steps.dispatch.outputs.dispatched }}
|
||||
event_types: ${{ steps.dispatch.outputs.event_types }}
|
||||
correlation_id: ${{ steps.dispatch.outputs.correlation_id }}
|
||||
trigger_comment_id: ${{ steps.dispatch.outputs.trigger_comment_id }}
|
||||
ack_comment_id: ${{ steps.ack.outputs.comment_id }}
|
||||
steps:
|
||||
- name: Guardrail — allow only members/collaborators
|
||||
id: guard
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const allowed = ['MEMBER','OWNER','COLLABORATOR'];
|
||||
const assoc = context.payload.comment.author_association;
|
||||
if (!allowed.includes(assoc)) {
|
||||
core.notice(`Ignoring /ci from ${context.payload.comment.user.login} (author_association=${assoc}).`);
|
||||
core.setOutput('skip', 'true');
|
||||
}
|
||||
|
||||
- name: Trigger repository dispatch
|
||||
id: dispatch
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pull_number = context.payload.issue.number;
|
||||
const comment = context.payload.comment;
|
||||
|
||||
// Fetch current PR state
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
|
||||
// Add reaction so folks know we saw it
|
||||
try {
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id,
|
||||
content: 'rocket'
|
||||
});
|
||||
} catch (e) {
|
||||
core.info('Could not add reaction (likely due to permissions). Continuing.');
|
||||
}
|
||||
|
||||
const labels = (pr.labels ?? []).map(label => label.name);
|
||||
const directCiLabels = labels.filter(label => label.startsWith('ci-'));
|
||||
const hasCiAll = directCiLabels.includes('ci-all');
|
||||
const dedupe = new Set(
|
||||
directCiLabels.filter(label => label !== 'ci-all')
|
||||
);
|
||||
|
||||
if (!hasCiAll && dedupe.size === 0) {
|
||||
core.notice('No ci-* labels found on the pull request; nothing to dispatch.');
|
||||
core.setOutput('dispatched', 'false');
|
||||
core.setOutput('event_types', '');
|
||||
return;
|
||||
}
|
||||
|
||||
const correlation_id = `id-${comment.id}-${Date.now().toString(36)}`;
|
||||
|
||||
const clientPayload = {
|
||||
correlation_id,
|
||||
pull_number,
|
||||
pr_ref: `refs/pull/${pull_number}/merge`,
|
||||
pr_head_ref: pr.head.ref,
|
||||
pr_head_sha: pr.head.sha,
|
||||
pr_base_ref: pr.base.ref,
|
||||
pr_base_sha: pr.base.sha,
|
||||
trigger_comment_id: comment.id,
|
||||
trigger_comment_user: comment.user.login,
|
||||
};
|
||||
|
||||
const eventTypes = hasCiAll
|
||||
? ['ci-all']
|
||||
: Array.from(dedupe);
|
||||
for (const eventType of eventTypes) {
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: eventType,
|
||||
client_payload: { ...clientPayload, ci_label: eventType }
|
||||
});
|
||||
core.notice(`Dispatched '${eventType}' event for PR #${pull_number}.`);
|
||||
}
|
||||
|
||||
core.setOutput('dispatched', 'true');
|
||||
core.setOutput('event_types', eventTypes.join(','));
|
||||
core.setOutput('correlation_id', correlation_id);
|
||||
core.setOutput('trigger_comment_id', String(comment.id));
|
||||
|
||||
- name: Acknowledge in thread (optional)
|
||||
if: steps.guard.outputs.skip != 'true' && steps.dispatch.outputs.dispatched == 'true'
|
||||
id: ack
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
EVENT_TYPES: ${{ steps.dispatch.outputs.event_types }}
|
||||
CORRELATION_ID: ${{ steps.dispatch.outputs.correlation_id }}
|
||||
with:
|
||||
script: |
|
||||
const eventTypes = (process.env.EVENT_TYPES || '')
|
||||
.split(',')
|
||||
.map(label => label.trim())
|
||||
.filter(Boolean);
|
||||
const formatted = eventTypes.map(label => `\`repository_dispatch:${label}\``).join(', ');
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = context.payload.issue.number;
|
||||
const body = [
|
||||
`✅ CI trigger requested by @${context.payload.comment.user.login}.`,
|
||||
`Fired ${formatted}.`,
|
||||
'',
|
||||
`_Collecting run links for correlation \`${process.env.CORRELATION_ID}\`…_`
|
||||
].join('\n');
|
||||
const { data: comment } = await github.rest.issues.createComment({
|
||||
owner, repo, issue_number,
|
||||
body
|
||||
});
|
||||
core.setOutput('comment_id', String(comment.id));
|
||||
|
||||
- name: Notify missing ci label
|
||||
if: steps.guard.outputs.skip != 'true' && steps.dispatch.outputs.dispatched != 'true'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = context.payload.issue.number;
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: `⚠️ CI trigger ignored because the pull request has no \`ci-*\` labels (e.g. \`ci-apo\`, \`ci-calc-x\`). Add the desired labels and try \`/ci\` again.`
|
||||
});
|
||||
|
||||
watch:
|
||||
needs: dispatch
|
||||
if: needs.dispatch.outputs.dispatched == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Track dispatched runs and update comment
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
CORRELATION_ID: ${{ needs.dispatch.outputs.correlation_id }}
|
||||
ACK_COMMENT_ID: ${{ needs.dispatch.outputs.ack_comment_id }}
|
||||
TRIGGER_COMMENT_ID: ${{ needs.dispatch.outputs.trigger_comment_id }}
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const correlationId = process.env.CORRELATION_ID;
|
||||
if (!correlationId) {
|
||||
core.warning('No correlation id supplied; nothing to watch.');
|
||||
return;
|
||||
}
|
||||
|
||||
const ackCommentId = Number(process.env.ACK_COMMENT_ID || 0);
|
||||
if (!ackCommentId) {
|
||||
core.warning('No comment id available for updates; skipping watch.');
|
||||
return;
|
||||
}
|
||||
const triggerCommentId = Number(process.env.TRIGGER_COMMENT_ID || 0);
|
||||
if (!triggerCommentId) {
|
||||
core.warning('No trigger comment id available; skipping watch.');
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = `🚀 CI Watcher for correlation ${correlationId} triggered by comment ${triggerCommentId}`;
|
||||
core.notice(`Watching workflow runs for correlation '${correlationId}' using comment ${ackCommentId}.`);
|
||||
|
||||
function fmt(run) {
|
||||
const status = run.status;
|
||||
const conclusion = run.conclusion;
|
||||
const badge = status === 'completed'
|
||||
? (conclusion === 'success' ? '🟢' : conclusion === 'failure' ? '🔴' : '🟡')
|
||||
: (status === 'in_progress' ? '🟣' : '⚪️');
|
||||
const title = run.display_title || run.name || `run ${run.id}`;
|
||||
const statusText = status === 'completed' ? `${status}/${conclusion}` : status;
|
||||
return `- ${badge} [${title}](${run.html_url}) — \`${statusText}\``;
|
||||
}
|
||||
|
||||
const signatureOf = runs =>
|
||||
runs
|
||||
.map(run => `${run.id}:${run.status}/${run.conclusion || ''}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
|
||||
const deadlineMs = Date.now() + 175 * 60 * 1000; // 175 minutes
|
||||
let found = [];
|
||||
|
||||
async function searchOnce() {
|
||||
const runs = await github.paginate(
|
||||
github.rest.actions.listWorkflowRunsForRepo,
|
||||
{ owner, repo, event: 'repository_dispatch', per_page: 100 }
|
||||
);
|
||||
const cutoff = new Date(Date.now() - 60 * 60 * 1000); // last hour
|
||||
return runs.filter(run => {
|
||||
const createdAt = new Date(run.created_at);
|
||||
const title = String(run.display_title || run.name || '');
|
||||
return createdAt >= cutoff && title.includes(correlationId);
|
||||
});
|
||||
}
|
||||
|
||||
while (Date.now() < deadlineMs) {
|
||||
found = await searchOnce();
|
||||
if (found.length > 0) {
|
||||
core.notice(`Discovered ${found.length} workflow run(s) for correlation '${correlationId}'.`);
|
||||
break;
|
||||
}
|
||||
core.notice(`No runs found yet for correlation '${correlationId}'; retrying shortly.`);
|
||||
await new Promise(res => setTimeout(res, 10000));
|
||||
}
|
||||
|
||||
if (found.length === 0) {
|
||||
core.notice(`Watcher timed out with no runs for correlation '${correlationId}'; notifying thread.`);
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: ackCommentId,
|
||||
body: [
|
||||
prefix,
|
||||
`⚠️ I couldn't find any workflow runs for correlation \`${correlationId}\`.`,
|
||||
`They may be delayed or misconfigured.`
|
||||
].join('\n')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const runIds = new Set(found.map(run => run.id));
|
||||
let lastSignature = '';
|
||||
|
||||
async function refreshRuns() {
|
||||
const ids = Array.from(runIds);
|
||||
const refreshed = [];
|
||||
for (const id of ids) {
|
||||
const { data } = await github.rest.actions.getWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: id
|
||||
});
|
||||
refreshed.push(data);
|
||||
}
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
async function updateCommentIfChanged(runs, allDone) {
|
||||
const signature = signatureOf(runs);
|
||||
if (signature === lastSignature) {
|
||||
// Run statuses unchanged; skipping comment update.
|
||||
return;
|
||||
}
|
||||
lastSignature = signature;
|
||||
core.notice(`Updating comment ${ackCommentId} with ${runs.length} run status entries (allDone=${allDone}).`);
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: ackCommentId,
|
||||
body: [
|
||||
prefix,
|
||||
`🏃♀️ Tracking ${runs.length} workflow run(s):`,
|
||||
'',
|
||||
...runs.map(fmt),
|
||||
'',
|
||||
allDone ? '✅ All runs completed.' : '_Still running…_'
|
||||
].join('\n')
|
||||
});
|
||||
}
|
||||
|
||||
await updateCommentIfChanged(found, found.every(run => run.status === 'completed'));
|
||||
|
||||
while (Date.now() < deadlineMs) {
|
||||
const latest = await searchOnce();
|
||||
for (const run of latest) {
|
||||
if (!runIds.has(run.id)) {
|
||||
runIds.add(run.id);
|
||||
core.notice(`Detected additional run ${run.id} (${run.name || run.display_title || 'unnamed'}) for correlation '${correlationId}'.`);
|
||||
}
|
||||
}
|
||||
const current = await refreshRuns();
|
||||
const allDone = current.every(run => run.status === 'completed');
|
||||
await updateCommentIfChanged(current, allDone);
|
||||
if (allDone) {
|
||||
core.notice(`All runs for correlation '${correlationId}' completed; stopping watcher.`);
|
||||
break;
|
||||
}
|
||||
await new Promise(res => setTimeout(res, 60000));
|
||||
}
|
||||
|
||||
if (Date.now() >= deadlineMs) {
|
||||
core.warning(`Watcher hit the deadline while monitoring correlation '${correlationId}'.`);
|
||||
}
|
||||
@@ -2,8 +2,8 @@ name: PyPI Nightly Build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 6:00 AM UTC
|
||||
- cron: '0 6 * * *'
|
||||
# Run daily at 6:00 AM UTC+8
|
||||
- cron: '0 22 * * *'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
@@ -14,18 +14,25 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Sync dependencies
|
||||
run: uv sync --frozen --no-default-groups --group dev
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e .[dev]
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install JavaScript dependencies
|
||||
run: cd dashboard && npm ci
|
||||
- name: Build dashboard
|
||||
run: cd dashboard && npm run build
|
||||
|
||||
- name: Get current version
|
||||
id: get_version
|
||||
@@ -44,16 +51,9 @@ jobs:
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
hatch build
|
||||
uv build
|
||||
|
||||
- name: Publish to Test PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
|
||||
- name: Test installation from Test PyPI
|
||||
run: |
|
||||
# Wait a bit for the package to be available
|
||||
sleep 30
|
||||
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ agentlightning
|
||||
python -c "import agentlightning; print('Package installed successfully')"
|
||||
|
||||
@@ -48,34 +48,34 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Sync dependencies
|
||||
run: uv sync --frozen --no-default-groups --group dev
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e .[dev]
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install JavaScript dependencies
|
||||
run: cd dashboard && npm ci
|
||||
- name: Build dashboard
|
||||
run: cd dashboard && npm run build
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
hatch build
|
||||
uv build
|
||||
|
||||
- name: Verify package contents
|
||||
run: |
|
||||
python -m tarfile -l dist/*.tar.gz
|
||||
python -m zipfile -l dist/*.whl
|
||||
uv run --locked --no-sync python -m tarfile -l dist/*.tar.gz
|
||||
uv run --locked --no-sync python -m zipfile -l dist/*.whl
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
- name: Test installation from PyPI
|
||||
run: |
|
||||
# Wait a bit for the package to be available
|
||||
sleep 30
|
||||
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ agentlightning
|
||||
python -c "import agentlightning; print('Package installed successfully')"
|
||||
|
||||
@@ -8,62 +8,315 @@ on:
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
repository_dispatch:
|
||||
types: [ci-gpu, ci-all]
|
||||
|
||||
run-name: >-
|
||||
${{ github.event_name == 'repository_dispatch'
|
||||
&& format(
|
||||
'GPU Test - PR #{0} - {1} - {2}',
|
||||
github.event.client_payload.pull_number,
|
||||
github.event.client_payload.ci_label,
|
||||
github.event.client_payload.correlation_id
|
||||
)
|
||||
|| format('GPU Test - {0}', github.event_name) }}
|
||||
|
||||
jobs:
|
||||
tests-full:
|
||||
if: >
|
||||
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 }})
|
||||
|
||||
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix:
|
||||
setup: [stable, latest]
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create a virtual environment
|
||||
run: python3 -m venv .venv
|
||||
- name: Install dependencies (${{ matrix.setup }})
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
./scripts/setup_${{ matrix.setup }}_gpu.sh
|
||||
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 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'
|
||||
- name: Freeze dependencies
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
which python
|
||||
which pip
|
||||
which uvx
|
||||
pip list | tee requirements-freeze.txt
|
||||
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-${{ matrix.setup }}
|
||||
name: dependencies-tests-full-${{ matrix.python-version }}-${{ matrix.setup-script }}
|
||||
path: requirements-freeze.txt
|
||||
compression-level: 0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install JavaScript dependencies
|
||||
run: cd dashboard && npm ci
|
||||
- name: Build dashboard
|
||||
run: cd dashboard && npm run build
|
||||
|
||||
- name: Setup Docker environments
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd docker
|
||||
|
||||
# Setup data directories
|
||||
./setup.sh
|
||||
|
||||
# Start Dockers
|
||||
docker compose -f compose.mongo.yml up -d
|
||||
|
||||
SERVICE_NAME=mongo
|
||||
TIMEOUT=60 # seconds
|
||||
SLEEP=2
|
||||
|
||||
cid="$(docker compose -f compose.mongo.yml ps -q "$SERVICE_NAME")"
|
||||
if [ -z "$cid" ]; then
|
||||
echo "Service $SERVICE_NAME is not running"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for $SERVICE_NAME to become healthy..."
|
||||
end=$((SECONDS + TIMEOUT))
|
||||
|
||||
while [ "$SECONDS" -lt "$end" ]; do
|
||||
status="$(docker inspect -f '{{.State.Health.Status}}' "$cid")"
|
||||
echo "Current status: $status"
|
||||
|
||||
if [ "$status" = "healthy" ]; then
|
||||
echo "$SERVICE_NAME is healthy ✅"
|
||||
exit 0
|
||||
elif [ "$status" = "unhealthy" ]; then
|
||||
echo "$SERVICE_NAME is unhealthy ❌"
|
||||
docker logs "$cid" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep "$SLEEP"
|
||||
done
|
||||
|
||||
echo "Timed out waiting for $SERVICE_NAME to become healthy after ${TIMEOUT}s"
|
||||
docker logs "$cid" || true
|
||||
exit 1
|
||||
shell: bash
|
||||
|
||||
- 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: Run tests
|
||||
run: |
|
||||
uv run pytest -v --durations=0 tests
|
||||
env:
|
||||
PYTEST_ADDOPTS: "--color=yes"
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
AGL_TEST_MONGO_URI: mongodb://localhost:27017/?replicaSet=rs0
|
||||
|
||||
|
||||
minimal-examples:
|
||||
if: >
|
||||
github.event_name != 'repository_dispatch' ||
|
||||
github.event.action == 'ci-gpu' ||
|
||||
github.event.action == 'ci-all'
|
||||
name: Minimal Examples with Python ${{ matrix.python-version }} (${{ matrix.setup-script }})
|
||||
|
||||
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
|
||||
timeout-minutes: 30
|
||||
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
|
||||
- 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 apo --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 --group dev --group agents --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-minimal-examples-${{ matrix.python-version }}-${{ matrix.setup-script }}
|
||||
path: requirements-freeze.txt
|
||||
compression-level: 0
|
||||
|
||||
- name: Launch LiteLLM Proxy
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
litellm --config scripts/litellm_ci.yaml --port 12306 &
|
||||
sleep 10 # Wait for the proxy to be up
|
||||
./scripts/litellm_run.sh
|
||||
env:
|
||||
AZURE_API_BASE: ${{ secrets.AZURE_API_BASE }}
|
||||
AZURE_API_KEY: ${{ secrets.AZURE_API_KEY }}
|
||||
- name: Verify LiteLLM Proxy
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
python scripts/litellm_sanity_check.py
|
||||
env:
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
AZURE_API_BASE: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_BASE }}
|
||||
AZURE_API_KEY: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_KEY }}
|
||||
|
||||
- name: Run tests
|
||||
- name: Write Traces via Otel Tracer
|
||||
run: |
|
||||
set -ex
|
||||
. .venv/bin/activate
|
||||
pytest -v --durations=0 tests
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/minimal
|
||||
python write_traces.py otel
|
||||
sleep 5
|
||||
|
||||
- name: Write Traces via AgentOps Tracer
|
||||
env:
|
||||
PYTEST_ADDOPTS: "--color=yes"
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/minimal
|
||||
python write_traces.py agentops
|
||||
sleep 5
|
||||
|
||||
- name: Write Traces via Otel Tracer with Client
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/minimal
|
||||
agl store --port 45993 --log-level DEBUG &
|
||||
sleep 5
|
||||
python write_traces.py otel --use-client
|
||||
pkill -f agl && echo "SIGTERM sent to agl" || echo "No agl process found"
|
||||
while pgrep -f agl; do
|
||||
echo "Waiting for agl to finish..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Write Traces via AgentOps Tracer with Client
|
||||
env:
|
||||
OPENAI_BASE_URL: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/minimal
|
||||
agl store --port 45993 --log-level DEBUG &
|
||||
sleep 5
|
||||
python write_traces.py agentops --use-client
|
||||
pkill -f agl && echo "SIGTERM sent to agl" || echo "No agl process found"
|
||||
while pgrep -f agl; do
|
||||
echo "Waiting for agl to finish..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: vLLM Server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/minimal
|
||||
python vllm_server.py Qwen/Qwen2.5-0.5B-Instruct
|
||||
|
||||
- name: LLM Proxy (OpenAI backend)
|
||||
env:
|
||||
OPENAI_API_BASE: http://localhost:12306/
|
||||
OPENAI_API_KEY: dummy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/minimal
|
||||
|
||||
python llm_proxy.py openai gpt-4.1-mini &
|
||||
|
||||
LLM_PROXY_READY=0
|
||||
for attempt in $(seq 1 30); do
|
||||
if curl -sSf http://localhost:43886/health > /dev/null 2>&1; then
|
||||
LLM_PROXY_READY=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [[ "$LLM_PROXY_READY" != "1" ]]; then
|
||||
echo "LLM proxy failed to become healthy" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python llm_proxy.py test gpt-4.1-mini
|
||||
|
||||
pkill -f llm_proxy.py && echo "SIGTERM sent to llm_proxy.py" || echo "No llm_proxy.py process found"
|
||||
while pgrep -f llm_proxy.py; do
|
||||
echo "Waiting for llm_proxy.py to finish..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: LLM Proxy (vLLM backend)
|
||||
if: matrix.setup-script != 'legacy' # Skip if return_token_ids is not supported
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .venv/bin/activate
|
||||
cd examples/minimal
|
||||
python llm_proxy.py vllm Qwen/Qwen2.5-0.5B-Instruct &
|
||||
|
||||
LLM_PROXY_READY=0
|
||||
for attempt in $(seq 1 30); do
|
||||
if curl -sSf http://localhost:43886/health > /dev/null 2>&1; then
|
||||
LLM_PROXY_READY=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [[ "$LLM_PROXY_READY" != "1" ]]; then
|
||||
echo "LLM proxy failed to become healthy" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python llm_proxy.py test Qwen/Qwen2.5-0.5B-Instruct
|
||||
|
||||
pkill -f llm_proxy.py && echo "SIGTERM sent to llm_proxy.py" || echo "No llm_proxy.py process found"
|
||||
while pgrep -f llm_proxy.py; do
|
||||
echo "Waiting for llm_proxy.py to finish..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
+114
-46
@@ -5,9 +5,9 @@ permissions:
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, stable/**/* ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [ main, stable/**/* ]
|
||||
workflow_dispatch:
|
||||
|
||||
schedule:
|
||||
@@ -16,70 +16,95 @@ on:
|
||||
|
||||
jobs:
|
||||
|
||||
lint-fast:
|
||||
name: Lint - Fast
|
||||
lint:
|
||||
strategy:
|
||||
matrix:
|
||||
setup: [fast, slow]
|
||||
name: Lint - ${{ matrix.setup }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
- name: Install dependencies
|
||||
- name: Sync dependencies (fast)
|
||||
run: uv sync --frozen --group dev --no-default-groups
|
||||
if: matrix.setup == 'fast'
|
||||
- name: Sync dependencies (slow)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e .[dev]
|
||||
uv sync --frozen \
|
||||
--extra apo \
|
||||
--extra verl \
|
||||
--extra mongo \
|
||||
--group dev \
|
||||
--group torch-cpu \
|
||||
--group torch-stable \
|
||||
--group trl \
|
||||
--group tinker \
|
||||
--group agents \
|
||||
--no-default-groups
|
||||
if: matrix.setup == 'slow'
|
||||
# This pre-commit skips JavaScript on purpose.
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
- name: Check Python headers
|
||||
run: |
|
||||
python scripts/check_python_headers.py
|
||||
run: uv run --locked --no-sync scripts/check_headers.py
|
||||
- name: Run Black
|
||||
run: black --check .
|
||||
run: uv run --locked --no-sync black --check .
|
||||
- name: Run isort
|
||||
run: isort --check-only .
|
||||
- name: Run pyright
|
||||
run: pyright -p pyrightconfig.fast.json
|
||||
run: uv run --locked --no-sync isort --check-only .
|
||||
- name: Run pyright (fast)
|
||||
run: uv run --locked --no-sync pyright -p pyrightconfig.fast.json
|
||||
if: matrix.setup == 'fast'
|
||||
- name: Run pyright (slow)
|
||||
run: uv run --locked --no-sync pyright -p pyrightconfig.json
|
||||
if: matrix.setup == 'slow'
|
||||
|
||||
lint-slow:
|
||||
name: Lint - Slow
|
||||
lint-js:
|
||||
name: Lint - JavaScript
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
node-version: '22'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
./scripts/setup_type_checking.sh
|
||||
- name: Run Black
|
||||
run: black --check .
|
||||
- name: Run isort
|
||||
run: isort --check-only .
|
||||
- name: Run pyright
|
||||
run: pyright -p pyrightconfig.json
|
||||
run: cd dashboard && npm ci
|
||||
- name: Run ESLint
|
||||
run: cd dashboard && npm run eslint
|
||||
- name: Run Prettier
|
||||
run: cd dashboard && npm run prettier
|
||||
- name: Run Stylelint
|
||||
run: cd dashboard && npm run stylelint
|
||||
- name: Run Typecheck
|
||||
run: cd dashboard && npm run typecheck
|
||||
- name: Verify build
|
||||
run: cd dashboard && npm run build
|
||||
|
||||
docs:
|
||||
name: Build documentation
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install documentation dependencies
|
||||
run: |
|
||||
./scripts/setup_stable.sh
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Sync dependencies
|
||||
run: uv sync --frozen --no-default-groups --group dev
|
||||
- name: Set source commit for docs
|
||||
run: |
|
||||
echo "SOURCE_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
|
||||
- name: Build documentation
|
||||
run: |
|
||||
mkdocs build --strict
|
||||
run: uv run --locked --no-sync mkdocs build --strict
|
||||
- name: Upload docs artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -92,35 +117,78 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- python-version: '3.10'
|
||||
setup-script: 'legacy'
|
||||
- python-version: '3.11'
|
||||
setup-script: 'stable'
|
||||
- python-version: '3.12'
|
||||
setup-script: 'stable'
|
||||
- python-version: '3.13'
|
||||
setup-script: 'latest'
|
||||
- python-version: '3.12'
|
||||
setup-script: 'stable'
|
||||
fail-fast: false
|
||||
|
||||
name: Test with Python ${{ matrix.python-version }} (${{ matrix.setup-script }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
./scripts/setup_${{ matrix.setup-script }}.sh
|
||||
- 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 --group dev --group agents --group core-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 core-${{ matrix.setup-script }}
|
||||
if: matrix.setup-script != 'latest'
|
||||
- name: Freeze dependencies
|
||||
run: |
|
||||
pip list | tee requirements-freeze-${{ matrix.python-version }}-${{ matrix.setup-script }}.txt
|
||||
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-python-${{ matrix.python-version }}-${{ matrix.setup-script }}
|
||||
path: requirements-freeze-${{ matrix.python-version }}-${{ matrix.setup-script }}.txt
|
||||
name: dependencies-${{ matrix.python-version }}-${{ matrix.setup-script }}
|
||||
path: requirements-freeze.txt
|
||||
compression-level: 0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install JavaScript dependencies
|
||||
run: cd dashboard && npm ci
|
||||
- name: Build dashboard
|
||||
run: cd dashboard && npm run build
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest -v --durations=0 tests
|
||||
uv run pytest -v --durations=0 tests -m "not mongo"
|
||||
env:
|
||||
PYTEST_ADDOPTS: "--color=yes"
|
||||
|
||||
test-js:
|
||||
name: Test - JavaScript
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: '3.12'
|
||||
- name: Sync Python dependencies
|
||||
run: uv sync --frozen --no-default-groups --extra apo --group dev --group agents --group core-stable
|
||||
- name: Install JavaScript dependencies
|
||||
run: cd dashboard && npm ci
|
||||
- name: Run vitest
|
||||
run: cd dashboard && npm run vitest
|
||||
|
||||
+12
@@ -189,6 +189,9 @@ cython_debug/
|
||||
# you could uncomment the following to ignore the enitre vscode folder
|
||||
.vscode/
|
||||
|
||||
# Emacs backup files
|
||||
*~
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
@@ -204,3 +207,12 @@ cython_debug/
|
||||
|
||||
# Claude
|
||||
.claude/*.local.json
|
||||
|
||||
# Dashboard generated files
|
||||
agentlightning/dashboard/**/*.css
|
||||
agentlightning/dashboard/**/*.js
|
||||
agentlightning/dashboard/**/*.html
|
||||
agentlightning/dashboard/**/*.svg
|
||||
|
||||
# Docker data
|
||||
docker/data/
|
||||
|
||||
@@ -8,6 +8,8 @@ repos:
|
||||
exclude: ^mkdocs\.yml$
|
||||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
args: ["--maxkb=1024"]
|
||||
exclude: (^uv\.lock$)|(^docs/assets/.*\.svg$)
|
||||
- id: check-shebang-scripts-are-executable
|
||||
- id: detect-private-key
|
||||
- repo: https://github.com/pycqa/isort
|
||||
@@ -22,3 +24,53 @@ repos:
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
args: ["."]
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: prettier
|
||||
name: prettier (dashboard)
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
entry: >
|
||||
bash -c '
|
||||
cd dashboard || exit 1
|
||||
if [ -d node_modules ]; then
|
||||
echo "✅ node_modules already exists"
|
||||
npx prettier --cache --write "**/*.{ts,tsx,mjs,cjs}"
|
||||
else
|
||||
echo "⚠️ node_modules not found — npx is not reliable. Skipping."
|
||||
fi
|
||||
'
|
||||
|
||||
- id: eslint
|
||||
name: eslint (dashboard)
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
entry: >
|
||||
bash -c '
|
||||
cd dashboard || exit 1
|
||||
if [ -d node_modules ]; then
|
||||
echo "✅ node_modules already exists"
|
||||
npx eslint --cache --fix .
|
||||
else
|
||||
echo "⚠️ node_modules not found — npx is not reliable. Skipping."
|
||||
fi
|
||||
'
|
||||
|
||||
- id: stylelint
|
||||
name: stylelint (dashboard)
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
entry: >
|
||||
bash -c '
|
||||
cd dashboard || exit 1
|
||||
if [ -d node_modules ]; then
|
||||
echo "✅ node_modules already exists"
|
||||
npx stylelint --cache --fix "**/*.css"
|
||||
else
|
||||
echo "⚠️ node_modules not found — npx is not reliable. Skipping."
|
||||
fi
|
||||
'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -1,13 +1,14 @@
|
||||
<div style="text-align:center; margin-bottom:20px;">
|
||||
<img src="docs/assets/readme-banner.png" alt="Agent-lightning-banner" style="max-width:600px"/>
|
||||
</div>
|
||||
<p align="center">
|
||||
<img src="docs/assets/readme-banner.svg" alt="Agent-lightning-banner" style="width:600px"/>
|
||||
</p>
|
||||
|
||||
# Agent Lightning⚡
|
||||
|
||||
[](https://github.com/microsoft/agent-lightning/actions/workflows/tests.yml)
|
||||
[](https://github.com/microsoft/agent-lightning/actions/workflows/examples.yml)
|
||||
[](https://github.com/microsoft/agent-lightning/actions/workflows/badge-unit.yml)
|
||||
[](https://microsoft.github.io/agent-lightning/)
|
||||
[](https://badge.fury.io/py/agentlightning)
|
||||
[](LICENSE)
|
||||
[](https://deepwiki.com/microsoft/agent-lightning)
|
||||
[](https://discord.gg/RYk7CdvDR7)
|
||||
|
||||
**The absolute trainer to light up AI agents.**
|
||||
@@ -17,14 +18,36 @@ Join our [Discord community](https://discord.gg/RYk7CdvDR7) to connect with othe
|
||||
## ⚡ Core Features
|
||||
|
||||
- Turn your agent into an optimizable beast with **ZERO CODE CHANGE** (almost)! 💤
|
||||
- Build with **ANY** agent framework (LangChain, OpenAI Agent SDK, AutoGen, CrewAI, ...); or even WITHOUT agent framework (Python OpenAI). You name it! 🤖
|
||||
- Build with **ANY** agent framework (LangChain, OpenAI Agent SDK, AutoGen, CrewAI, Microsoft Agent Framework...); or even WITHOUT agent framework (Python OpenAI). You name it! 🤖
|
||||
- **Selectively** optimize one or more agents in a multi-agent system. 🎯
|
||||
- Embraces Reinforcement Learning, Automatic Prompt Optimization and more **algorithms**. 🤗
|
||||
- Embraces **Algorithms** like Reinforcement Learning, Automatic Prompt Optimization, Supervised Fine-tuning and more. 🤗
|
||||
|
||||

|
||||
Read more on our [documentation website](https://microsoft.github.io/agent-lightning/).
|
||||
|
||||
## ⚡ Resources
|
||||
<p align="center">
|
||||
<img src="docs/assets/readme-diff.svg" alt="Agent-Lightning Core Quickstart" style="width:100%"/>
|
||||
</p>
|
||||
|
||||
## ⚡ Installation
|
||||
|
||||
```bash
|
||||
pip install agentlightning
|
||||
```
|
||||
|
||||
For the latest nightly build (cutting-edge features), you can install from Test PyPI:
|
||||
|
||||
```bash
|
||||
pip install --upgrade --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ agentlightning
|
||||
```
|
||||
|
||||
Please refer to our [installation guide](https://microsoft.github.io/agent-lightning/stable/tutorials/installation/) for more details.
|
||||
|
||||
To start using Agent-lightning, check out our [documentation](https://microsoft.github.io/agent-lightning/) and [examples](./examples).
|
||||
|
||||
## ⚡ Articles
|
||||
|
||||
- 11/4/2025 [Tuning ANY AI agent with Tinker ✕ Agent-lightning](https://medium.com/@yugez/tuning-any-ai-agent-with-tinker-agent-lightning-part-1-1d8c9a397f0e) Medium. See also [Part 2](https://medium.com/@yugez/tuning-any-ai-agent-with-tinker-agent-lightning-part-2-332c5437f0dc).
|
||||
- 10/22/2025 [No More Retokenization Drift: Returning Token IDs via the OpenAI Compatible API Matters in Agent RL](https://blog.vllm.ai/2025/10/22/agent-lightning.html) vLLM blog. See also [Zhihu writeup](https://zhuanlan.zhihu.com/p/1965067274642785725).
|
||||
- 8/11/2025 [Training AI Agents to Write and Self-correct SQL with Reinforcement Learning](https://medium.com/@yugez/training-ai-agents-to-write-and-self-correct-sql-with-reinforcement-learning-571ed31281ad) Medium.
|
||||
- 8/5/2025 [Agent Lightning: Train ANY AI Agents with Reinforcement Learning](https://arxiv.org/abs/2508.03680) arXiv paper.
|
||||
- 7/26/2025 [We discovered an approach to train any AI agent with RL, with (almost) zero code changes.](https://www.reddit.com/r/LocalLLaMA/comments/1m9m670/we_discovered_an_approach_to_train_any_ai_agent/) Reddit.
|
||||
@@ -35,114 +58,28 @@ Join our [Discord community](https://discord.gg/RYk7CdvDR7) to connect with othe
|
||||
- [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.
|
||||
|
||||
## ⚡ Installation
|
||||
|
||||
First, let's get your environment set up. We'll be using `/path/to/agentlightning` to refer to the directory containing this README file.
|
||||
|
||||
### 1. Set Up Your Environment
|
||||
|
||||
We strongly recommend creating a new virtual environment to avoid conflicts with other packages. You can use either `conda` or `venv`. **Python 3.10 or later** is recommended.
|
||||
|
||||
### 2. Install Core Training Dependencies (Optional)
|
||||
|
||||
If you are running RL with Agent-Lightning, the next step is to install the essential packages: `PyTorch`, `FlashAttention`, `vLLM` and `VERL`. The following versions and installation order have been tested and are confirmed to work.
|
||||
|
||||
```bash
|
||||
pip install torch==2.7.0 torchvision==0.22.0 torchaudio==2.7.0 --index-url https://download.pytorch.org/whl/cu128
|
||||
pip install flash-attn --no-build-isolation
|
||||
pip install vllm==0.9.2
|
||||
pip install verl==0.5.0
|
||||
```
|
||||
|
||||
See `scripts/setup_stable_gpu.sh` for a full installation script.
|
||||
|
||||
### 3. Install Agent Lightning
|
||||
|
||||
Now, you're ready to install Agent Lightning itself.
|
||||
|
||||
```bash
|
||||
pip install agentlightning
|
||||
```
|
||||
|
||||
### 4. Install Agent Frameworks (Optional)
|
||||
|
||||
If you plan to use other agent frameworks, you can install them with the following commands. If you don't need these, feel free to skip this step.
|
||||
We recommend doing this as the final step to avoid dependency versions being overwritten by mistake.
|
||||
|
||||
```bash
|
||||
# AutoGen (Recommended to install first)
|
||||
pip install "autogen-agentchat" "autogen-ext[openai]"
|
||||
|
||||
# LiteLLM
|
||||
pip install "litellm[proxy]"
|
||||
|
||||
# MCP
|
||||
pip install mcp
|
||||
|
||||
# UV
|
||||
pip install uv
|
||||
|
||||
# OpenAI Agents
|
||||
pip install openai-agents
|
||||
|
||||
# LangChain
|
||||
pip install langgraph "langchain[openai]" langchain-community langchain-text-splitters
|
||||
|
||||
# SQL-related dependencies
|
||||
pip install sqlparse nltk
|
||||
```
|
||||
|
||||
Don't worry if dependency conflicts arise during this step. Follow the installation order above and the conflicts generally do not matter.
|
||||
|
||||
## ⚡ Examples
|
||||
|
||||
For more detailed examples, please see the `examples` folder:
|
||||
|
||||
1. [calc_x](examples/calc_x): An agent built with AutoGen with calculator tool use, trained on Calc-X dataset with Reinforcement Learning.
|
||||
2. [spider](examples/spider): A write-check-rewrite looped agent with LangGraph with SQL execution; selectively optimize write and rewrite on Spider dataset with Reinforcement Learning.
|
||||
3. [apo](examples/apo): An example to customize an optimization algorithm: Automatic Prompt Optimization.
|
||||
|
||||
## ⚡ Important Caveats
|
||||
|
||||
1. **AgentOps Integration**: Agent Lightning uses [AgentOps](https://github.com/AgentOps-AI/agentops) for agent tracking by default. If you're already using AgentOps in your own code, you'll need to disable our managed AgentOps client by modifying the `tracer` parameter of trainer.
|
||||
2. **Debugging Traces**: If you encounter issues with tracing, you can visualize the trace tree using `tracer.last_trace().visualize("tree_graph")`. Please note that this API is experimental and may change in future releases.
|
||||
3. **Launching the Server and Agents**: Currently, the training server and agent clients must be launched in separate processes. You can open two terminal windows or run one of them in the background. The launching order generally doesn't matter.
|
||||
4. **Environment Variables**: The environment variables and working directory at the time of `ray init` are important. If you run into "file not found" errors, try restarting Ray from your current working directory.
|
||||
5. **Handling Timeouts**: The training server may hang if samples fail or time out on the agent side. To prevent this, we recommend setting limits on the prompt and response lengths, as this is the most common cause of failures.
|
||||
6. **VERL Failures**: Save checkpoints frequently, as VERL with vLLM may sometimes experience out-of-memory issues. If you encounter a VERL failure, you can resume training from the last checkpoint.
|
||||
|
||||
## ⚡ Architecture
|
||||
|
||||
Currently, Agent Lightning is built around a **training server** and one or multiple **agents**.
|
||||
Agent Lightning keeps the moving parts to a minimum so you can focus on your idea, not the plumbing. Your agent continues to run as usual; you can still use any agent framework you like; you drop in the lightweight `agl.emit_xxx()` helper, or let the tracer collect every prompt, tool call, and reward. Those events become structured spans that flow into the LightningStore, a central hub that keeps tasks, resources, and traces in sync.
|
||||
|
||||
* The **server** manages the training data, prepares samples for the agents, and provides the LLM endpoint.
|
||||
* **Agents** retrieve samples from the server, process them (which may involve interacting with the LLM), and send the results back. These results, or "trajectories," are lists of prompts and responses from the LLM.
|
||||
* The **server** then collects these trajectories and computes the losses to optimize the language models.
|
||||
On the other side of the store sits the algorithm you choose, or write yourself. The algorithm reads spans, learns from them, and posts updated resources such as refined prompt templates or new policy weights. The Trainer ties it all together: it streams datasets to runners, ferries resources between the store and the algorithm, and updates the inference engine when improvements land. You can either stop there, or simply let the same loop keep turning.
|
||||
|
||||

|
||||
No rewrites, no lock-in, just a clear path from first rollout to steady improvement.
|
||||
|
||||
## ⚡ Development Instructions
|
||||
<p align="center">
|
||||
<img src="docs/assets/readme-architecture.svg" alt="Agent-lightning Architecture" style="width:100%"/>
|
||||
</p>
|
||||
|
||||
Install with development dependencies:
|
||||
## ⚡ CI Status
|
||||
|
||||
```
|
||||
git clone https://github.com/microsoft/agent-lightning
|
||||
cd agent-lightning
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
Please run pre-commit hooks before checking in code:
|
||||
|
||||
```
|
||||
pre-commit install
|
||||
pre-commit run --all-files --show-diff-on-failure --color=always
|
||||
```
|
||||
|
||||
Serve documentation locally:
|
||||
|
||||
```bash
|
||||
mkdocs serve
|
||||
```
|
||||
| Workflow | Status |
|
||||
|----------|--------|
|
||||
| CPU Tests | [](https://github.com/microsoft/agent-lightning/actions/workflows/tests.yml) |
|
||||
| Full Tests | [](https://github.com/microsoft/agent-lightning/actions/workflows/badge-unit.yml) |
|
||||
| UI Tests | [](https://github.com/microsoft/agent-lightning/actions/workflows/dashboard.yml) |
|
||||
| Examples Integration | [](https://github.com/microsoft/agent-lightning/actions/workflows/badge-examples.yml) |
|
||||
| Latest Dependency Compatibility | [](https://github.com/microsoft/agent-lightning/actions/workflows/badge-latest.yml) |
|
||||
| Legacy Examples Compatibility | [](https://github.com/microsoft/agent-lightning/actions/workflows/badge-compat.yml) |
|
||||
|
||||
## ⚡ Citation
|
||||
|
||||
@@ -162,7 +99,7 @@ If you find Agent Lightning useful in your research or projects, please cite our
|
||||
|
||||
## ⚡ Contributing
|
||||
|
||||
This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
|
||||
This project welcomes contributions and suggestions. Start by reading the [Contributing Guide](docs/community/contributing.md) for recommended contribution points, environment setup, branching conventions, and pull request expectations. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
|
||||
|
||||
When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA.
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
async def a():
|
||||
print("a")
|
||||
b()
|
||||
print("finish")
|
||||
|
||||
|
||||
def b():
|
||||
print("b")
|
||||
loop = asyncio.get_running_loop()
|
||||
fut = asyncio.run_coroutine_threadsafe(c(), loop)
|
||||
fut.result(timeout=5.0)
|
||||
|
||||
|
||||
async def c():
|
||||
print("c")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
asyncio.run(a())
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
from .adapter import *
|
||||
from .algorithm import *
|
||||
@@ -10,7 +10,9 @@ from .emitter import *
|
||||
from .execution import *
|
||||
from .litagent import *
|
||||
from .llm_proxy import *
|
||||
from .logging import *
|
||||
from .logging import configure_logger # deprecated # type: ignore
|
||||
from .logging import setup as setup_logging # type: ignore
|
||||
from .logging import setup_module as setup_module_logging # type: ignore
|
||||
from .runner import *
|
||||
from .server import AgentLightningServer # deprecated # type: ignore
|
||||
from .store import *
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from .base import Adapter, TraceAdapter
|
||||
from .base import Adapter, OtelTraceAdapter, TraceAdapter
|
||||
from .messages import TraceToMessages
|
||||
from .triplet import LlmProxyTraceToTriplet, TracerTraceToTriplet, TraceToTripletBase
|
||||
|
||||
__all__ = [
|
||||
"TraceAdapter",
|
||||
"OtelTraceAdapter",
|
||||
"Adapter",
|
||||
"TraceToTripletBase",
|
||||
"TracerTraceToTriplet",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Generic, List, TypeVar
|
||||
from typing import Generic, Sequence, TypeVar
|
||||
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
|
||||
@@ -13,18 +13,20 @@ T_to = TypeVar("T_to")
|
||||
class Adapter(Generic[T_from, T_to]):
|
||||
"""Base class for synchronous adapters that convert data from one format to another.
|
||||
|
||||
This class defines a simple protocol for transformation:
|
||||
The class defines a minimal protocol so that adapters can be treated like callables while
|
||||
still allowing subclasses to supply the concrete transformation logic.
|
||||
|
||||
- The `__call__` method makes adapters callable, so they can be used like functions.
|
||||
- Subclasses must implement the `adapt` method to define the actual conversion logic.
|
||||
!!! note
|
||||
Subclasses must override [`adapt()`][agentlightning.Adapter.adapt] to provide
|
||||
the actual conversion.
|
||||
|
||||
Type parameters:
|
||||
Type Variables:
|
||||
|
||||
- T_from: The source data type (input).
|
||||
- T_to: The target data type (output).
|
||||
T_from: Source data type supplied to the adapter.
|
||||
|
||||
Example:
|
||||
T_to: Target data type produced by the adapter.
|
||||
|
||||
Examples:
|
||||
>>> class IntToStrAdapter(Adapter[int, str]):
|
||||
... def adapt(self, source: int) -> str:
|
||||
... return str(source)
|
||||
@@ -37,8 +39,9 @@ class Adapter(Generic[T_from, T_to]):
|
||||
def __call__(self, source: T_from, /) -> T_to:
|
||||
"""Convert the data to the target format.
|
||||
|
||||
This method delegates to `adapt` and allows the adapter
|
||||
to be invoked as a function.
|
||||
This method delegates to [`adapt()`][agentlightning.Adapter.adapt] so that an
|
||||
instance of [`Adapter`][agentlightning.Adapter] can be used like a standard
|
||||
function.
|
||||
|
||||
Args:
|
||||
source: Input data in the source format.
|
||||
@@ -51,8 +54,8 @@ class Adapter(Generic[T_from, T_to]):
|
||||
def adapt(self, source: T_from, /) -> T_to:
|
||||
"""Convert the data to the target format.
|
||||
|
||||
Subclasses should override this method with the concrete
|
||||
transformation logic.
|
||||
Subclasses must override this method with the concrete transformation logic. The base
|
||||
implementation raises `NotImplementedError` to make the requirement explicit.
|
||||
|
||||
Args:
|
||||
source: Input data in the source format.
|
||||
@@ -63,20 +66,15 @@ class Adapter(Generic[T_from, T_to]):
|
||||
raise NotImplementedError("Adapter.adapt() is not implemented")
|
||||
|
||||
|
||||
class OtelTraceAdapter(Adapter[List[ReadableSpan], T_to], Generic[T_to]):
|
||||
class OtelTraceAdapter(Adapter[Sequence[ReadableSpan], T_to], Generic[T_to]):
|
||||
"""Base class for adapters that convert OpenTelemetry trace spans into other formats.
|
||||
|
||||
This class specializes `Adapter` for working with OpenTelemetry `ReadableSpan`
|
||||
objects. It expects a list of spans as input and produces a custom target format
|
||||
(e.g., reinforcement learning training data, SFT datasets, logs, metrics).
|
||||
This specialization of [`Adapter`][agentlightning.Adapter] expects a list of
|
||||
`opentelemetry.sdk.trace.ReadableSpan` instances and produces any target format, such as
|
||||
reinforcement learning trajectories, structured logs, or analytics-ready payloads.
|
||||
|
||||
Subclasses should override `adapt` to define the desired conversion.
|
||||
|
||||
Type parameters:
|
||||
T_to: The target data type that spans should be converted into.
|
||||
|
||||
Example:
|
||||
>>> class TraceToDictAdapter(TraceAdapter[dict]):
|
||||
Examples:
|
||||
>>> class TraceToDictAdapter(OtelTraceAdapter[dict]):
|
||||
... def adapt(self, spans: List[ReadableSpan]) -> dict:
|
||||
... return {"count": len(spans)}
|
||||
...
|
||||
@@ -86,10 +84,11 @@ class OtelTraceAdapter(Adapter[List[ReadableSpan], T_to], Generic[T_to]):
|
||||
"""
|
||||
|
||||
|
||||
class TraceAdapter(Adapter[List[Span], T_to], Generic[T_to]):
|
||||
class TraceAdapter(Adapter[Sequence[Span], T_to], Generic[T_to]):
|
||||
"""Base class for adapters that convert trace spans into other formats.
|
||||
|
||||
This class specializes `Adapter` for working with trace spans. It expects a list of
|
||||
Agent-lightning spans as input and produces a custom target format
|
||||
(e.g., reinforcement learning training data, SFT datasets, logs, metrics).
|
||||
This class specializes [`Adapter`][agentlightning.Adapter] for working with
|
||||
[`Span`][agentlightning.Span] instances emitted by Agent Lightning instrumentation.
|
||||
Subclasses receive entire trace slices and return a format suited for the downstream consumer,
|
||||
for example reinforcement learning training data or observability metrics.
|
||||
"""
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, Generator, Iterable, List, Optional, TypedDict, Union, cast
|
||||
from typing import TYPE_CHECKING, Any, Dict, Generator, Iterable, List, Optional, Sequence, TypedDict, Union, cast
|
||||
|
||||
from openai.types.chat import (
|
||||
ChatCompletionAssistantMessageParam,
|
||||
ChatCompletionFunctionToolParam,
|
||||
ChatCompletionMessageFunctionToolCallParam,
|
||||
ChatCompletionMessageParam,
|
||||
)
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from agentlightning.types import Span
|
||||
|
||||
from .base import TraceAdapter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai.types.chat import (
|
||||
ChatCompletionFunctionToolParam,
|
||||
ChatCompletionMessageFunctionToolCallParam,
|
||||
ChatCompletionMessageParam,
|
||||
)
|
||||
|
||||
|
||||
class OpenAIMessages(TypedDict):
|
||||
"""OpenAI-style chat messages with optional tool definitions.
|
||||
|
||||
Attributes:
|
||||
messages: Ordered chat messages that describe the conversation.
|
||||
tools: Tool specifications available to the assistant, if any.
|
||||
"""
|
||||
|
||||
messages: List[ChatCompletionMessageParam]
|
||||
tools: Optional[List[ChatCompletionFunctionToolParam]]
|
||||
|
||||
|
||||
class _RawSpanInfo(TypedDict):
|
||||
"""Intermediate representation parsed from a span.
|
||||
|
||||
Attributes:
|
||||
prompt: Prompt messages reconstructed from span attributes.
|
||||
completion: Assistant completions following tool invocations.
|
||||
request: Request payload recorded in the trace.
|
||||
response: Response payload recorded in the trace.
|
||||
tools: Tool call metadata extracted from child spans.
|
||||
"""
|
||||
|
||||
prompt: List[Dict[str, Any]]
|
||||
completion: List[Dict[str, Any]]
|
||||
request: Dict[str, Any]
|
||||
@@ -31,16 +51,20 @@ class _RawSpanInfo(TypedDict):
|
||||
|
||||
|
||||
def group_genai_dict(data: Dict[str, Any], prefix: str) -> Union[Dict[str, Any], List[Any]]:
|
||||
"""
|
||||
Convert a flat dict with keys like 'gen_ai.prompt.0.role'
|
||||
into structured nested dicts or lists under the given prefix.
|
||||
"""Convert flattened trace attributes into nested structures.
|
||||
|
||||
Attributes emitted by the tracing pipeline often arrive as dotted paths (for example
|
||||
`gen_ai.prompt.0.role`). This helper groups those keys into nested dictionaries or lists so that
|
||||
downstream processing can operate on structured data.
|
||||
|
||||
Args:
|
||||
data: Flat dictionary (keys are dotted paths).
|
||||
prefix: Top-level key to extract (e.g., 'gen_ai.prompt').
|
||||
data: Flat dictionary whose keys are dotted paths.
|
||||
prefix: Top-level key (for example `gen_ai.prompt`) that determines which attributes are
|
||||
grouped.
|
||||
|
||||
Returns:
|
||||
A nested dict (if no index detected) or list (if indexed).
|
||||
A nested dictionary (no numeric index detected) or list (numeric indices detected) containing
|
||||
the grouped values.
|
||||
"""
|
||||
result: Union[Dict[str, Any], List[Any]] = {}
|
||||
|
||||
@@ -80,12 +104,28 @@ def group_genai_dict(data: Dict[str, Any], prefix: str) -> Union[Dict[str, Any],
|
||||
|
||||
|
||||
def convert_to_openai_messages(prompt_completion_list: List[_RawSpanInfo]) -> Generator[OpenAIMessages, None, None]:
|
||||
"""
|
||||
Convert raw tool call traces + prompt/completion list
|
||||
into OpenAI fine-tuning JSONL format (tool calling style).
|
||||
"""Convert raw trace payloads into OpenAI-style chat messages.
|
||||
|
||||
https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/fine-tuning-functions
|
||||
The function consumes an iterable produced by
|
||||
[`TraceToMessages.adapt()`][agentlightning.TraceToMessages.adapt] and yields
|
||||
structures that match the OpenAI fine-tuning JSONL schema, including tool definitions.
|
||||
|
||||
Args:
|
||||
prompt_completion_list: Raw prompt/completion/tool payloads extracted from a trace.
|
||||
|
||||
Returns:
|
||||
A generator that yields [`OpenAIMessages`][agentlightning.adapter.messages.OpenAIMessages]
|
||||
entries compatible with the OpenAI Functions fine-tuning format.
|
||||
"""
|
||||
|
||||
# Import locally to avoid legacy OpenAI version type import errors
|
||||
from openai.types.chat import (
|
||||
ChatCompletionAssistantMessageParam,
|
||||
ChatCompletionFunctionToolParam,
|
||||
ChatCompletionMessageFunctionToolCallParam,
|
||||
ChatCompletionMessageParam,
|
||||
)
|
||||
|
||||
for pc_entry in prompt_completion_list:
|
||||
messages: List[ChatCompletionMessageParam] = []
|
||||
|
||||
@@ -157,25 +197,29 @@ def convert_to_openai_messages(prompt_completion_list: List[_RawSpanInfo]) -> Ge
|
||||
|
||||
|
||||
class TraceToMessages(TraceAdapter[List[OpenAIMessages]]):
|
||||
"""
|
||||
Adapter that converts OpenTelemetry trace spans into OpenAI-compatible message format.
|
||||
"""Convert trace spans into OpenAI-compatible conversation messages.
|
||||
|
||||
This adapter processes trace spans containing LLM conversation data and transforms them
|
||||
into structured OpenAI message format suitable for fine-tuning or analysis. It extracts
|
||||
prompts, completions, tool calls, and function definitions from trace attributes and
|
||||
reconstructs the conversation flow.
|
||||
The adapter reconstructs prompts, completions, tool calls, and function definitions from
|
||||
`gen_ai.*` span attributes. The resulting objects match the JSONL structure expected by the
|
||||
OpenAI fine-tuning pipeline.
|
||||
|
||||
The adapter handles:
|
||||
- Converting flat trace attributes into structured message objects
|
||||
- Extracting and matching tool calls with their corresponding requests
|
||||
- Building proper OpenAI ChatCompletionMessage objects with roles, content, and tool calls
|
||||
- Generating function definitions for tools used in conversations
|
||||
!!! warning
|
||||
The adapter assumes all spans share a common trace and that tool call spans are direct
|
||||
children of the associated completion span.
|
||||
"""
|
||||
|
||||
def get_tool_calls(self, completion: Span, all_spans: List[Span], /) -> Iterable[Dict[str, Any]]:
|
||||
"""Find tool calls in the trace. Returns a dict with the tool call id, name, and arguments.
|
||||
def get_tool_calls(self, completion: Span, all_spans: Sequence[Span], /) -> Iterable[Dict[str, Any]]:
|
||||
"""Yield tool call payloads for a completion span.
|
||||
|
||||
The spans that are direct children of the completion span are the tool calls.
|
||||
Args:
|
||||
completion: The completion span whose descendants should be inspected.
|
||||
all_spans: The complete span list belonging to the trace.
|
||||
|
||||
Yields:
|
||||
Dictionaries describing tool calls with identifiers, names, and arguments.
|
||||
|
||||
Raises:
|
||||
ValueError: If a candidate tool span cannot be converted into a dictionary.
|
||||
"""
|
||||
# Get all the spans that are children of the completion span
|
||||
children = [span for span in all_spans if span.parent_id == completion.span_id]
|
||||
@@ -187,7 +231,16 @@ class TraceToMessages(TraceAdapter[List[OpenAIMessages]]):
|
||||
if tool_call:
|
||||
yield tool_call
|
||||
|
||||
def adapt(self, source: List[Span], /) -> List[OpenAIMessages]:
|
||||
def adapt(self, source: Sequence[Span], /) -> List[OpenAIMessages]:
|
||||
"""Transform trace spans into OpenAI chat payloads.
|
||||
|
||||
Args:
|
||||
source: Spans containing `gen_ai.*` attributes emitted by the tracing pipeline.
|
||||
|
||||
Returns:
|
||||
A list of [`OpenAIMessages`][agentlightning.adapter.messages.OpenAIMessages] entries that
|
||||
capture prompts, completions, tools, and metadata.
|
||||
"""
|
||||
raw_prompt_completions: List[_RawSpanInfo] = []
|
||||
|
||||
for span in source:
|
||||
|
||||
+212
-112
@@ -3,23 +3,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union, cast
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union, cast
|
||||
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agentlightning.types import SpanNames, Triplet
|
||||
from agentlightning.types.tracer import Span
|
||||
from agentlightning.types import Span, SpanNames, Triplet
|
||||
|
||||
from .base import TraceAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Transition(BaseModel):
|
||||
"""
|
||||
Transition class representing one transition in a trajectory.
|
||||
State and action are a list of token IDs.
|
||||
"""A single transition within a reinforcement learning trajectory.
|
||||
|
||||
Attributes:
|
||||
state: Token identifiers describing the model input state.
|
||||
action: Token identifiers representing the model output.
|
||||
response_id: Identifier of the LLM response used to deduplicate spans.
|
||||
agent_name: Human-readable agent name captured from the trace.
|
||||
reward: Scalar reward associated with the transition, if available.
|
||||
"""
|
||||
|
||||
state: List[int]
|
||||
@@ -31,22 +38,27 @@ class Transition(BaseModel):
|
||||
|
||||
|
||||
class RewardMatchPolicy(str, Enum):
|
||||
"""How to find the reward for each transition from the trace.
|
||||
In all cases, the reward must have data `{"type": "reward", "value": <float>|None}`,
|
||||
as defined in `reward.py`.
|
||||
"""Strategies for matching rewards to LLM call spans.
|
||||
|
||||
!!! note
|
||||
Each reward span must expose a payload shaped like `{"type": "reward", "value": <float>|None}`
|
||||
as described in `reward.py`.
|
||||
"""
|
||||
|
||||
FIRST_SIBLING = "first_sibling"
|
||||
"""Use the first sibling in the current trace subtree as the reward, except another LLM call match is found."""
|
||||
"""Use the first sibling in the current trace subtree as the reward unless another LLM call match is found."""
|
||||
|
||||
FIRST_OCCURRENCE = "first_occurrence"
|
||||
"""Use the first occurrence of the reward (in start time order) that occur after the current LLM call match.
|
||||
"""
|
||||
"""Use the first reward encountered in chronological order after the current LLM call match."""
|
||||
|
||||
|
||||
class TraceTree:
|
||||
"""
|
||||
A trace item, along with its span and children.
|
||||
"""Tree representation of a trace span and its descendants.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for the span node.
|
||||
span: [`Span`][agentlightning.Span] backing this node.
|
||||
children: Child nodes connected to the current span.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -80,10 +92,16 @@ class TraceTree:
|
||||
self.children.append(child)
|
||||
|
||||
def visualize(self, filename: str, interested_span_match: str | None = None) -> None:
|
||||
"""
|
||||
Visualize the trace tree using graphviz.
|
||||
For debugging purposes only.
|
||||
Use `interested_span_match` to filter the spans (and its ancesters) to be visualized.
|
||||
"""Render the trace tree with Graphviz for debugging purposes.
|
||||
|
||||
Args:
|
||||
filename: Base filename for the generated `.png` diagram.
|
||||
interested_span_match: Optional regular expression used to keep only matching spans
|
||||
(and their ancestors) in the output.
|
||||
|
||||
!!! note
|
||||
The method requires the optional `graphviz` dependency to be available in the runtime
|
||||
environment.
|
||||
"""
|
||||
import graphviz
|
||||
|
||||
@@ -125,9 +143,11 @@ class TraceTree:
|
||||
dot.render(filename, format="png", cleanup=True) # type: ignore
|
||||
|
||||
def names_tuple(self) -> Tuple[str, List[Any]]:
|
||||
"""Return the span name, and a list of children.
|
||||
Each child is also a tuple of span name and a list of children.
|
||||
Useful for debugging and testing.
|
||||
"""Return the span name alongside nested child names.
|
||||
|
||||
Returns:
|
||||
A tuple of the current span name and a list of tuples for each child containing the
|
||||
child name and its descendants.
|
||||
"""
|
||||
name = self.span.name
|
||||
agent_name = self.agent_name()
|
||||
@@ -140,15 +160,14 @@ class TraceTree:
|
||||
return name, children_names
|
||||
|
||||
def traverse(self) -> List["TraceTree"]:
|
||||
"""
|
||||
Traverse the trace tree and return a list of all spans.
|
||||
"""
|
||||
"""Traverse the tree depth first and return every node."""
|
||||
spans: List["TraceTree"] = [self]
|
||||
for child in self.children:
|
||||
spans.extend(child.traverse())
|
||||
return spans
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
"""Convert the tree node into a JSON-serialisable structure."""
|
||||
if isinstance(self.span, ReadableSpan):
|
||||
span_data = json.loads(self.span.to_json())
|
||||
else:
|
||||
@@ -161,10 +180,17 @@ class TraceTree:
|
||||
|
||||
@classmethod
|
||||
def from_spans(cls, spans: List[Span]) -> "TraceTree":
|
||||
"""
|
||||
Create a TraceTree from a list of spans.
|
||||
All spans without parents found will be considered as candidate root spans.
|
||||
If multiple root spans are found, a virtual root span will be created as the parent of all root spans.
|
||||
"""Construct a tree from a flat list of spans.
|
||||
|
||||
Args:
|
||||
spans: Spans that collectively form a single trace segment.
|
||||
|
||||
Returns:
|
||||
A [`TraceTree`][agentlightning.adapter.triplet.TraceTree] rooted at either the
|
||||
discovered root span or a synthetic root when multiple roots are present.
|
||||
|
||||
Raises:
|
||||
ValueError: If the span list is empty or no root span can be inferred.
|
||||
"""
|
||||
|
||||
if not spans:
|
||||
@@ -245,8 +271,11 @@ class TraceTree:
|
||||
return root_span
|
||||
|
||||
def agent_name(self) -> Optional[str]:
|
||||
"""Return the name of agent span. Return the agent or None (not an agent at all).
|
||||
Extend this function to support more agent frameworks."""
|
||||
"""Return the agent name associated with the span, if any.
|
||||
|
||||
Returns:
|
||||
Agent name extracted from known attributes, otherwise `None`.
|
||||
"""
|
||||
attributes = self.span.attributes
|
||||
if attributes is None: # type: ignore
|
||||
return None
|
||||
@@ -279,6 +308,11 @@ class TraceTree:
|
||||
return agent_name
|
||||
|
||||
def maybe_reward_dict(self) -> dict[str, Any]:
|
||||
"""Return a reward payload if the span encodes one.
|
||||
|
||||
Returns:
|
||||
Dictionary containing reward metadata, or an empty dictionary when no reward is found.
|
||||
"""
|
||||
for key in [
|
||||
"agentops.task.output", # newer versions of agentops
|
||||
"agentops.entity.output",
|
||||
@@ -299,6 +333,11 @@ class TraceTree:
|
||||
return {}
|
||||
|
||||
def is_reward_span(self) -> bool:
|
||||
"""Return whether the span explicitly encodes a reward.
|
||||
|
||||
Returns:
|
||||
`True` when the span payload describes a reward, otherwise `False`.
|
||||
"""
|
||||
maybe_reward = self.maybe_reward_dict()
|
||||
return maybe_reward and maybe_reward.get("type") == "reward" # type: ignore
|
||||
|
||||
@@ -312,12 +351,19 @@ class TraceTree:
|
||||
within_llm_call: Optional[bool] = None,
|
||||
existing_llm_call_response_ids: Optional[set[str]] = None,
|
||||
) -> List[Tuple["TraceTree", str]]:
|
||||
"""Find all LLM calls in the trace tree.
|
||||
"""Find LLM call spans matching the supplied filters.
|
||||
|
||||
The LLM call is defined as a span with type = request and name matching `llm_call_match`.
|
||||
If `agent_match` is not None, it must also reside in an agent span (type = agent) with name matched.
|
||||
Args:
|
||||
llm_call_match: Regular expression used to match span names that qualify as LLM calls.
|
||||
agent_match: Optional regular expression that must match the enclosing agent span name.
|
||||
within_matching_subtree: Marker propagated through recursive calls to record matching agents.
|
||||
within_reward: When `True`, suppresses LLM matches under reward spans.
|
||||
within_llm_call: When `True`, prevents duplicate matches for nested LLM calls.
|
||||
existing_llm_call_response_ids: Known response identifiers used to deduplicate spans.
|
||||
|
||||
Return a list of traces and the agent names (why it's selected).
|
||||
Returns:
|
||||
A list of tuples pairing the matching node with the agent subtree label that triggered the
|
||||
match.
|
||||
"""
|
||||
llm_calls: List[Tuple[TraceTree, str]] = []
|
||||
|
||||
@@ -373,19 +419,26 @@ class TraceTree:
|
||||
return llm_calls
|
||||
|
||||
def repair_hierarchy(self) -> None:
|
||||
"""
|
||||
We find that sometimes the hierarchy is not correct, due to the way the spans are created.
|
||||
The spans within the agent frameworks (e.g., OpenAI Agent SDK) and spans within the LLM frameworks
|
||||
(e.g., Anthropic) are created in two systems.
|
||||
So the inner LLM completion span does not necessarily have an agent span as a parent.
|
||||
Rather they sometimes directly become children of the root span.
|
||||
This becomes a problem when we want to select the LLM completion span with agent as filter.
|
||||
To repair the hierarchy, for each children of the root span, we find a span over the whole tree,
|
||||
with duration covering the current span and being closest to the current span.
|
||||
"""Repair missing parent-child relationships introduced by mixed tracing systems.
|
||||
|
||||
This function modifies the tree in place.
|
||||
Some agent frameworks emit spans via multiple subsystems, which can cause LLM completion
|
||||
spans to float directly under the root span instead of being nested under the correct agent.
|
||||
The method re-parents those spans to the closest ancestor that fully envelopes the child in
|
||||
time.
|
||||
|
||||
If we don't, when we want to select the LLM completion span with agent as filter.
|
||||
We will never get the correct span underneath.
|
||||
"""
|
||||
# If the current node has only one child, recursively repair its hierarchy directly.
|
||||
# This special-case handling is needed because when a trace is manually ended
|
||||
# (via agentops.end_trace), the AgentOps provider automatically wraps all spans
|
||||
# under an extra synthetic root node (e.g., "run_one.session").
|
||||
if len(self.children) == 1:
|
||||
self.children[0].repair_hierarchy()
|
||||
return
|
||||
|
||||
nodes_to_repair = list(self.children)
|
||||
|
||||
for repair_node in nodes_to_repair:
|
||||
if len(self.children) == 1:
|
||||
# If there is only one child, we don't need to repair the hierarchy.
|
||||
@@ -410,7 +463,16 @@ class TraceTree:
|
||||
closest_parent.children.append(repair_node)
|
||||
|
||||
def match_rewards(self, reward_match: str, llm_calls: List["TraceTree"]) -> dict[str, Optional[float]]:
|
||||
"""Match the rewards to the LLM calls."""
|
||||
"""Assign rewards to previously matched LLM calls.
|
||||
|
||||
Args:
|
||||
reward_match: Strategy identifier from
|
||||
[`RewardMatchPolicy`][agentlightning.adapter.triplet.RewardMatchPolicy].
|
||||
llm_calls: Trace nodes representing LLM call spans.
|
||||
|
||||
Returns:
|
||||
Mapping from span identifier to reward value or `None` when no reward is available.
|
||||
"""
|
||||
llm_call_ids = set([llm_call.id for llm_call in llm_calls])
|
||||
rewards: dict[str, Optional[float]] = {}
|
||||
|
||||
@@ -455,6 +517,30 @@ class TraceTree:
|
||||
|
||||
return rewards
|
||||
|
||||
def span_to_triplet(self, span: Span, agent_name: str) -> Triplet:
|
||||
"""Convert a span to a triplet.
|
||||
|
||||
Subclass can override this method to add more fields to the triplet,
|
||||
such as chat messages and tool calls.
|
||||
"""
|
||||
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
|
||||
|
||||
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}
|
||||
|
||||
return Triplet(
|
||||
prompt={"token_ids": prompt_token_ids},
|
||||
response=response,
|
||||
reward=None,
|
||||
metadata=dict(response_id=response_id, agent_name=agent_name),
|
||||
)
|
||||
|
||||
def to_trajectory(
|
||||
self,
|
||||
llm_call_match: str = r"openai\.chat\.completion",
|
||||
@@ -463,20 +549,21 @@ class TraceTree:
|
||||
dedup_llm_call: bool = True,
|
||||
reward_match: RewardMatchPolicy = RewardMatchPolicy.FIRST_OCCURRENCE,
|
||||
final_reward: Optional[float] = None,
|
||||
_skip_empty_token_spans: bool = False,
|
||||
) -> List[Triplet]:
|
||||
"""Convert the trace tree to a trajectory.
|
||||
"""Convert the trace tree into a trajectory of [`Triplet`][agentlightning.Triplet] items.
|
||||
|
||||
First, we find all the LLM calls (span type = request, `llm_call_match` matching the span name).
|
||||
If the agent match is set, we check, for each LLM call,
|
||||
if it resides in an agent (span type = agent, `agent_match` matching the span name).
|
||||
The above sets the basis for the trajectory, as we use the prompt token IDs and response token IDs for each LLM call,
|
||||
as the state and action of each transition.
|
||||
Args:
|
||||
llm_call_match: Regular expression for LLM call span names.
|
||||
agent_match: Optional regular expression for agent span names.
|
||||
exclude_llm_call_in_reward: When `True`, prevents searching for rewards under the LLM
|
||||
call subtree.
|
||||
dedup_llm_call: When `True`, deduplicates spans using the LLM response identifier.
|
||||
reward_match: Reward matching policy used to associate reward spans with LLM calls.
|
||||
final_reward: Optional reward appended to the final transition when provided.
|
||||
|
||||
Then, we find the reward for each transition.
|
||||
The reward is searched on the trace tree, after the LLM call,
|
||||
until the next LLM call or the end of the tree depending on the policy.
|
||||
It can be enforced to a sibling or the first occurrence in the time order, depending on the policy.
|
||||
If a reward is never found for a transition, it is set to None.
|
||||
Returns:
|
||||
A list of [`Triplet`][agentlightning.Triplet] objects ordered by call sequence.
|
||||
"""
|
||||
# Find all LLM calls
|
||||
llm_calls = self.find_llm_calls(
|
||||
@@ -487,25 +574,23 @@ class TraceTree:
|
||||
within_llm_call=False if dedup_llm_call else None,
|
||||
existing_llm_call_response_ids=set(),
|
||||
)
|
||||
id_transitions = [
|
||||
(
|
||||
llm_call.id,
|
||||
Triplet(
|
||||
prompt={"token_ids": llm_call.span.attributes.get("prompt_token_ids", [])}, # type: ignore
|
||||
response={"token_ids": llm_call.span.attributes.get("response_token_ids", [])}, # type: ignore
|
||||
reward=None,
|
||||
metadata=dict(
|
||||
response_id=llm_call.span.attributes.get( # type: ignore
|
||||
"gen_ai.response.id", None
|
||||
), # it works at least for OpenAI
|
||||
agent_name=agent_name,
|
||||
),
|
||||
),
|
||||
)
|
||||
for llm_call, agent_name in llm_calls
|
||||
]
|
||||
|
||||
rewards = self.match_rewards(reward_match, [call for call, _ in llm_calls])
|
||||
id_transitions: List[Tuple[str, Triplet]] = []
|
||||
# We need to filter out the LLM calls with unrecorded token IDs
|
||||
filtered_llm_calls: List[Tuple[TraceTree, str]] = []
|
||||
for llm_call, agent_name in llm_calls:
|
||||
triplet = self.span_to_triplet(llm_call.span, agent_name)
|
||||
# This is a hot-fix for Tinker+CrewAI, which has some anonymous requests outside the trained agent.
|
||||
# TODO: We might need to reconsider this.
|
||||
if _skip_empty_token_spans and (
|
||||
not triplet.prompt.get("token_ids") or not triplet.response.get("token_ids")
|
||||
):
|
||||
logger.warning(f"Skipping LLM call with unrecorded token IDs: {triplet}")
|
||||
continue
|
||||
filtered_llm_calls.append((llm_call, agent_name))
|
||||
id_transitions.append((llm_call.id, triplet))
|
||||
|
||||
rewards = self.match_rewards(reward_match, [call for call, _ in filtered_llm_calls])
|
||||
transitions = [
|
||||
transition.model_copy(update={"reward": rewards.get(id, None)}) for id, transition in id_transitions
|
||||
]
|
||||
@@ -522,22 +607,22 @@ class TraceTree:
|
||||
|
||||
|
||||
class TraceToTripletBase(TraceAdapter[List[Triplet]]):
|
||||
"""
|
||||
Base class for trace triplet adapters.
|
||||
"""
|
||||
"""Base class for adapters that emit [`Triplet`][agentlightning.Triplet] trajectories."""
|
||||
|
||||
|
||||
class TracerTraceToTriplet(TraceToTripletBase):
|
||||
"""
|
||||
An adapter to convert OpenTelemetry spans to triplet data.
|
||||
"""Convert tracer-emitted spans into triplet trajectories.
|
||||
|
||||
Attributes:
|
||||
repair_hierarchy: When `repair_hierarchy` is set to True, the trace will be repaired with the time information.
|
||||
See `TraceTree.repair_hierarchy` for more details.
|
||||
llm_call_match: Regular expression pattern to match LLM call span names.
|
||||
agent_match: Optional regular expression pattern to match agent span names. If None, all agents are matched.
|
||||
exclude_llm_call_in_reward: Whether to exclude LLM calls that occur within reward spans.
|
||||
reward_match: Policy for matching rewards to LLM calls.
|
||||
repair_hierarchy: When `True`, repair the span tree using
|
||||
[`TraceTree.repair_hierarchy()`][agentlightning.adapter.triplet.TraceTree.repair_hierarchy]
|
||||
before matching calls and rewards.
|
||||
llm_call_match: Regular expression pattern that selects LLM call span names.
|
||||
agent_match: Optional regular expression pattern for agent span names. When omitted, spans
|
||||
from any agent are considered.
|
||||
exclude_llm_call_in_reward: When `True`, ignore matches under reward spans while searching
|
||||
for rewards.
|
||||
reward_match: Strategy used to associate rewards with LLM calls.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -547,12 +632,14 @@ class TracerTraceToTriplet(TraceToTripletBase):
|
||||
agent_match: Optional[str] = None,
|
||||
exclude_llm_call_in_reward: bool = True,
|
||||
reward_match: RewardMatchPolicy = RewardMatchPolicy.FIRST_OCCURRENCE,
|
||||
_skip_empty_token_spans: bool = False,
|
||||
):
|
||||
self.repair_hierarchy = repair_hierarchy
|
||||
self.llm_call_match = llm_call_match
|
||||
self.agent_match = agent_match
|
||||
self.exclude_llm_call_in_reward = exclude_llm_call_in_reward
|
||||
self.reward_match = reward_match
|
||||
self._skip_empty_token_spans = _skip_empty_token_spans
|
||||
|
||||
def visualize(
|
||||
self,
|
||||
@@ -561,16 +648,17 @@ class TracerTraceToTriplet(TraceToTripletBase):
|
||||
filename: str = "trace_tree",
|
||||
interested_span_match: str | None = None,
|
||||
) -> TraceTree:
|
||||
"""
|
||||
Visualize the trace tree.
|
||||
"""Visualize the trace tree built from the supplied spans.
|
||||
|
||||
Args:
|
||||
source (List[Span]): The list of OpenTelemetry spans to visualize.
|
||||
filename (str): The base filename for the output visualization (default: "trace_tree").
|
||||
interested_span_match (str | None): Optional regular expression pattern to highlight or focus on specific spans in the visualization.
|
||||
source: Collection of Agent Lightning [`Span`][agentlightning.Span] objects
|
||||
or raw `opentelemetry.sdk.trace.ReadableSpan` instances.
|
||||
filename: Base filename for the generated image; `.png` is appended automatically.
|
||||
interested_span_match: Optional regular expression used to highlight a subset of spans.
|
||||
|
||||
Returns:
|
||||
TraceTree: The constructed trace tree object.
|
||||
The [`TraceTree`][agentlightning.adapter.triplet.TraceTree] built from the provided
|
||||
spans.
|
||||
"""
|
||||
source_normalized = [
|
||||
Span.from_opentelemetry(span, "dummy", "dummy", 0) if isinstance(span, ReadableSpan) else span
|
||||
@@ -582,8 +670,15 @@ class TracerTraceToTriplet(TraceToTripletBase):
|
||||
trace_tree.visualize(filename, interested_span_match=interested_span_match)
|
||||
return trace_tree
|
||||
|
||||
def adapt(self, source: Union[List[Span], List[ReadableSpan]], /) -> List[Triplet]: # type: ignore
|
||||
"""Convert OpenTelemetry spans to a list of Triplet objects."""
|
||||
def adapt(self, source: Union[Sequence[Span], Sequence[ReadableSpan]], /) -> List[Triplet]: # type: ignore
|
||||
"""Convert tracer spans into [`Triplet`][agentlightning.Triplet] trajectories.
|
||||
|
||||
Args:
|
||||
source: Agent Lightning spans or raw OpenTelemetry spans that form a trace.
|
||||
|
||||
Returns:
|
||||
Ordered list of trajectory transitions with prompt, response, and reward information.
|
||||
"""
|
||||
source_normalized = [
|
||||
Span.from_opentelemetry(span, "dummy", "dummy", 0) if isinstance(span, ReadableSpan) else span
|
||||
for span in source
|
||||
@@ -596,30 +691,29 @@ class TracerTraceToTriplet(TraceToTripletBase):
|
||||
agent_match=self.agent_match,
|
||||
exclude_llm_call_in_reward=self.exclude_llm_call_in_reward,
|
||||
reward_match=self.reward_match,
|
||||
_skip_empty_token_spans=self._skip_empty_token_spans,
|
||||
)
|
||||
return trajectory
|
||||
|
||||
|
||||
class LlmProxyTraceToTriplet(TraceToTripletBase):
|
||||
"""
|
||||
Converting telemetry data emitted by the LLM Proxy to triplet data.
|
||||
This adapter is very experimental. Should only be used when the TracerTraceToTriplet does not work at all.
|
||||
"""Convert telemetry emitted by the LLM Proxy into triplet trajectories.
|
||||
|
||||
IMPORTANT: Do NOT rely on timestamps here. Proxy spans can be emitted from different
|
||||
machines with unsynchronized clocks. We therefore treat `sequence_id` as the only
|
||||
reliable ordering primitive and perform "first occurrence" reward matching using
|
||||
sequence order only.
|
||||
!!! warning
|
||||
This adapter is experimental and might be merged with
|
||||
[`TracerTraceToTriplet`][agentlightning.TracerTraceToTriplet] in the future.
|
||||
|
||||
!!! danger
|
||||
Do not rely on timestamps when using this adapter. Proxy spans can originate on different
|
||||
machines with unsynchronised clocks, so `sequence_id` is treated as the sole source of
|
||||
ordering.
|
||||
|
||||
Strategy:
|
||||
|
||||
1) Sort spans by (sequence_id, start_time).
|
||||
2) Extract LLM calls that expose prompt/response token IDs from either:
|
||||
- litellm_request (sometimes only metadata, ignore if no token ids)
|
||||
- raw_gen_ai_request (llm.hosted_vllm.* stringified fields)
|
||||
3) Extract rewards from spans whose attributes contain an AgentOps-style
|
||||
reward payload or explicit REWARD span.
|
||||
4) For each reward with sequence R, assign it to the most recent *unmatched* LLM call
|
||||
with sequence < R. Ignore timestamps completely.
|
||||
1. Sort spans by `(sequence_id, start_time)` for deterministic processing.
|
||||
2. Extract token identifiers from `litellm_request` or `raw_gen_ai_request` spans.
|
||||
3. Extract rewards from spans exposing AgentOps-style payloads or explicit reward spans.
|
||||
4. Match each reward to the most recent unmatched LLM call whose sequence is smaller.
|
||||
"""
|
||||
|
||||
def _literal_eval_maybe(self, v: Any) -> Any:
|
||||
@@ -681,9 +775,7 @@ class LlmProxyTraceToTriplet(TraceToTripletBase):
|
||||
return cast(List[int], prompt_ids), cast(List[int], resp_ids)
|
||||
|
||||
def _maybe_reward_value(self, span: Span) -> Optional[float]:
|
||||
"""
|
||||
Parse reward from typical AgentOps payload or explicit REWARD span.
|
||||
"""
|
||||
"""Parse reward from typical AgentOps payloads or explicit reward spans."""
|
||||
attrs = span.attributes or {}
|
||||
|
||||
# AgentOps new/old keys
|
||||
@@ -708,7 +800,15 @@ class LlmProxyTraceToTriplet(TraceToTripletBase):
|
||||
rid = attrs.get("gen_ai.response.id") or attrs.get("llm.hosted_vllm.id")
|
||||
return str(rid) if isinstance(rid, str) and rid else None
|
||||
|
||||
def adapt(self, source: List[Span], /) -> List[Triplet]: # type: ignore
|
||||
def adapt(self, source: Sequence[Span], /) -> List[Triplet]: # type: ignore
|
||||
"""Convert LLM Proxy spans into [`Triplet`][agentlightning.Triplet] trajectories.
|
||||
|
||||
Args:
|
||||
source: Spans emitted by the LLM Proxy containing prompt, response, and reward data.
|
||||
|
||||
Returns:
|
||||
Ordered trajectory transitions matched purely by `sequence_id`.
|
||||
"""
|
||||
# 1) Sort deterministically by (sequence_id, start_time).
|
||||
spans = sorted(
|
||||
source,
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .base import BaseAlgorithm
|
||||
from .base import Algorithm
|
||||
from .decorator import algo
|
||||
from .fast import Baseline, FastAlgorithm
|
||||
|
||||
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
from .apo import APO as APOType
|
||||
from .verl import VERL as VERLType
|
||||
|
||||
__all__ = ["BaseAlgorithm", "algo", "FastAlgorithm", "Baseline", "APO", "VERL"]
|
||||
__all__ = ["Algorithm", "algo", "FastAlgorithm", "Baseline", "APO", "VERL"]
|
||||
|
||||
# Shortcuts for usages like algo.APO(...)
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ import poml
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from agentlightning.adapter.messages import TraceToMessages
|
||||
from agentlightning.algorithm.base import BaseAlgorithm
|
||||
from agentlightning.algorithm.base import Algorithm
|
||||
from agentlightning.algorithm.utils import batch_iter_over_dataset
|
||||
from agentlightning.reward import find_final_reward
|
||||
from agentlightning.types import Dataset, NamedResources, PromptTemplate, Rollout, RolloutMode, RolloutStatus
|
||||
|
||||
@@ -56,42 +57,7 @@ APPLY_EDIT_PROMPT_FILES = [
|
||||
]
|
||||
|
||||
|
||||
def batch_iter_over_dataset(dataset: Dataset[T_task], batch_size: int) -> Iterator[Sequence[T_task]]:
|
||||
"""
|
||||
Create an infinite iterator that yields batches from the dataset.
|
||||
|
||||
When batch_size >= dataset size, yields the entire shuffled dataset repeatedly.
|
||||
When batch_size < dataset size, yields batches of the specified size, reshuffling
|
||||
after each complete pass through the dataset.
|
||||
|
||||
Args:
|
||||
dataset: The dataset to iterate over.
|
||||
batch_size: The desired batch size.
|
||||
|
||||
Yields:
|
||||
Sequences of tasks from the dataset. Each task appears at most once per epoch.
|
||||
"""
|
||||
if batch_size >= len(dataset):
|
||||
while True:
|
||||
dataset_copy = [dataset[i] for i in range(len(dataset))]
|
||||
random.shuffle(dataset_copy)
|
||||
yield dataset_copy
|
||||
|
||||
else:
|
||||
current_batch: List[int] = []
|
||||
while True:
|
||||
indices = list(range(len(dataset)))
|
||||
random.shuffle(indices)
|
||||
for index in indices:
|
||||
if index in current_batch:
|
||||
continue
|
||||
current_batch.append(index)
|
||||
if len(current_batch) == batch_size:
|
||||
yield [dataset[index] for index in current_batch]
|
||||
current_batch = []
|
||||
|
||||
|
||||
class APO(BaseAlgorithm, Generic[T_task]):
|
||||
class APO(Algorithm, Generic[T_task]):
|
||||
"""Automatic Prompt Optimization (APO) algorithm using textual gradients and beam search.
|
||||
|
||||
APO is an iterative prompt optimization algorithm that uses LLM-generated textual gradients
|
||||
@@ -99,14 +65,16 @@ class APO(BaseAlgorithm, Generic[T_task]):
|
||||
computes critiques based on the results, and applies edits to generate improved prompts.
|
||||
|
||||
The algorithm operates in rounds, where each round:
|
||||
|
||||
1. Samples parent prompts from the current beam
|
||||
2. Generates new prompts by computing textual gradients and applying edits
|
||||
3. Evaluates all candidates on a validation set
|
||||
4. Selects the top-k prompts for the next round
|
||||
|
||||
Based on the ideas from:
|
||||
- ProTeGi: https://aclanthology.org/2023.emnlp-main.494.pdf
|
||||
- TextGrad: https://github.com/zou-group/textgrad
|
||||
|
||||
- [ProTeGi](https://aclanthology.org/2023.emnlp-main.494.pdf)
|
||||
- [TextGrad](https://github.com/zou-group/textgrad)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -337,6 +305,7 @@ class APO(BaseAlgorithm, Generic[T_task]):
|
||||
Generate an improved prompt by computing a textual gradient and applying an edit.
|
||||
|
||||
This is the main optimization step that:
|
||||
|
||||
1. Computes a critique (textual gradient) based on rollout performance
|
||||
2. Uses another LLM to apply the critique and generate an improved prompt
|
||||
|
||||
@@ -443,6 +412,7 @@ class APO(BaseAlgorithm, Generic[T_task]):
|
||||
Evaluate a prompt on a batch of tasks by running rollouts and computing average reward.
|
||||
|
||||
This method:
|
||||
|
||||
1. Adds the prompt as a named resource to the store
|
||||
2. Enqueues rollouts for each task in the dataset
|
||||
3. Waits for rollouts to complete (with timeout)
|
||||
@@ -587,6 +557,7 @@ class APO(BaseAlgorithm, Generic[T_task]):
|
||||
Generate new candidate prompts from parents using textual gradients.
|
||||
|
||||
For each parent prompt, generates branch_factor new candidates by:
|
||||
|
||||
1. Evaluating the parent on a training batch
|
||||
2. Computing textual gradient
|
||||
3. Applying edit to generate improved prompt
|
||||
@@ -814,6 +785,7 @@ class APO(BaseAlgorithm, Generic[T_task]):
|
||||
Execute the APO algorithm to optimize prompts through beam search with textual gradients.
|
||||
|
||||
The algorithm performs iterative prompt optimization over multiple rounds:
|
||||
|
||||
- Each round: samples parent prompts, generates new candidates via textual gradients,
|
||||
evaluates all candidates on validation data, and keeps the top performers
|
||||
- Tracks the historically best prompt across all rounds
|
||||
|
||||
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
|
||||
from agentlightning.trainer import Trainer
|
||||
|
||||
|
||||
class BaseAlgorithm:
|
||||
class Algorithm:
|
||||
"""Algorithm is the strategy, or tuner to train the agent."""
|
||||
|
||||
_trainer_ref: weakref.ReferenceType[Trainer] | None = None
|
||||
|
||||
@@ -26,7 +26,7 @@ from agentlightning.types import Dataset, NamedResources
|
||||
if TYPE_CHECKING:
|
||||
from agentlightning.llm_proxy import LLMProxy
|
||||
|
||||
from .base import BaseAlgorithm
|
||||
from .base import Algorithm
|
||||
|
||||
# Algorithm function signature types
|
||||
# We've missed a lot of combinations here.
|
||||
@@ -100,12 +100,13 @@ AsyncFlag = Literal[True, False]
|
||||
AF = TypeVar("AF", bound=AsyncFlag)
|
||||
|
||||
|
||||
class FunctionalAlgorithm(BaseAlgorithm, Generic[AF]):
|
||||
"""A BaseAlgorithm that wraps a function-based algorithm implementation.
|
||||
class FunctionalAlgorithm(Algorithm, Generic[AF]):
|
||||
"""An algorithm wrapper built from a callable implementation.
|
||||
|
||||
This class allows users to define algorithm behavior using a simple function
|
||||
that takes train_dataset and val_dataset parameters, rather than implementing
|
||||
a full BaseAlgorithm subclass.
|
||||
Functional algorithms let you provide an ordinary function instead of
|
||||
subclassing [`Algorithm`][agentlightning.Algorithm]. The wrapper inspects
|
||||
the callable signature to supply optional dependencies
|
||||
such as the store, adapter, and LLM proxy.
|
||||
"""
|
||||
|
||||
@overload
|
||||
@@ -115,13 +116,12 @@ class FunctionalAlgorithm(BaseAlgorithm, Generic[AF]):
|
||||
def __init__(self: "FunctionalAlgorithm[Literal[True]]", algorithm_func: AlgorithmFuncAsyncLike) -> None: ...
|
||||
|
||||
def __init__(self, algorithm_func: Union[AlgorithmFuncSyncLike, AlgorithmFuncAsyncLike]) -> None:
|
||||
"""
|
||||
Initialize the FunctionalAlgorithm with an algorithm function.
|
||||
"""Wrap a function that implements algorithm behaviour.
|
||||
|
||||
Args:
|
||||
algorithm_func: A function that defines the algorithm's behavior.
|
||||
Can be sync or async with signature:
|
||||
(train_dataset, val_dataset) -> None
|
||||
algorithm_func: Sync or async callable implementing the algorithm
|
||||
contract. Arguments are detected automatically based on the
|
||||
function signature.
|
||||
"""
|
||||
super().__init__()
|
||||
self._algorithm_func = algorithm_func
|
||||
@@ -156,14 +156,20 @@ class FunctionalAlgorithm(BaseAlgorithm, Generic[AF]):
|
||||
train_dataset: Optional[Dataset[Any]] = None,
|
||||
val_dataset: Optional[Dataset[Any]] = None,
|
||||
) -> Union[None, Awaitable[None]]:
|
||||
"""Execute the algorithm using the wrapped function.
|
||||
"""Execute the wrapped function with injected dependencies.
|
||||
|
||||
Args:
|
||||
train_dataset: The dataset to train on.
|
||||
val_dataset: The dataset to validate on.
|
||||
train_dataset: Optional training dataset passed through when the
|
||||
callable declares a `train_dataset` parameter.
|
||||
val_dataset: Optional validation dataset passed through when the
|
||||
callable declares a `val_dataset` parameter.
|
||||
|
||||
Returns:
|
||||
None or Awaitable[None] if the function is async.
|
||||
None for sync callables or an awaitable when the callable is async.
|
||||
|
||||
Raises:
|
||||
TypeError: If a dataset is provided but the function signature does
|
||||
not accept the corresponding argument.
|
||||
"""
|
||||
kwargs: Dict[str, Any] = {}
|
||||
if "store" in self._sig.parameters:
|
||||
@@ -217,40 +223,42 @@ def algo(
|
||||
AlgorithmFuncAsyncFallback,
|
||||
],
|
||||
) -> Union[FunctionalAlgorithm[Literal[False]], FunctionalAlgorithm[Literal[True]]]:
|
||||
"""Create a BaseAlgorithm from a function.
|
||||
"""Convert a callable into a [`FunctionalAlgorithm`][agentlightning.algorithm.decorator.FunctionalAlgorithm].
|
||||
|
||||
This decorator allows you to define an algorithm using a simple function
|
||||
instead of creating a full BaseAlgorithm subclass. The returned FunctionalAlgorithm
|
||||
instance is callable, preserving the original function's behavior.
|
||||
The decorator inspects the callable signature to decide which dependencies
|
||||
to inject at runtime, enabling concise algorithm definitions that still
|
||||
leverage the full training runtime.
|
||||
|
||||
Args:
|
||||
func: A function that defines the algorithm's behavior with signature:
|
||||
(train_dataset, val_dataset) -> None
|
||||
Can be sync or async.
|
||||
func: Function implementing the algorithm logic. May be synchronous or
|
||||
asynchronous. The function can expect all of, or a subset of the following parameters:
|
||||
|
||||
- `store`: [`LightningStore`][agentlightning.store.base.LightningStore],
|
||||
- `train_dataset`: [`Dataset`][agentlightning.Dataset],
|
||||
- `val_dataset`: [`Dataset`][agentlightning.Dataset],
|
||||
- `llm_proxy`: [`LLMProxy`][agentlightning.LLMProxy],
|
||||
- `adapter`: [`TraceAdapter`][agentlightning.TraceAdapter],
|
||||
- `initial_resources`: [`NamedResources`][agentlightning.NamedResources],
|
||||
|
||||
If the function does not expect a parameter, the wrapper will not inject it into the call.
|
||||
Using `*args` and `**kwargs` will not work and no parameters will be injected.
|
||||
|
||||
Returns:
|
||||
A callable FunctionalAlgorithm instance that preserves the original function's
|
||||
type hints and behavior while providing all algorithm functionality.
|
||||
FunctionalAlgorithm that proxies the callable while exposing the
|
||||
`Algorithm` interface.
|
||||
|
||||
Example:
|
||||
@algo
|
||||
def my_algorithm(train_dataset, val_dataset):
|
||||
# Algorithm logic here
|
||||
for task in train_dataset:
|
||||
# Process training tasks
|
||||
pass
|
||||
Examples:
|
||||
```python
|
||||
from agentlightning.algorithm.decorator import algo
|
||||
|
||||
@algo
|
||||
async def my_async_algorithm(train_dataset, val_dataset):
|
||||
# Async algorithm logic here
|
||||
async for task in train_dataset:
|
||||
# Process training tasks asynchronously
|
||||
pass
|
||||
def batching_algorithm(*, store, train_dataset, val_dataset):
|
||||
for sample in train_dataset:
|
||||
store.enqueue_rollout(input=sample, mode="train")
|
||||
|
||||
# Function is still callable with original behavior
|
||||
my_algorithm(train_data, val_data)
|
||||
|
||||
# Algorithm methods are also available
|
||||
my_algorithm.run(train_data, val_data)
|
||||
@algo
|
||||
async def async_algorithm(*, store, train_dataset=None, val_dataset=None):
|
||||
await store.enqueue_rollout(input={"prompt": "hello"}, mode="train")
|
||||
```
|
||||
"""
|
||||
return FunctionalAlgorithm(func)
|
||||
|
||||
@@ -7,21 +7,21 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Literal, Optional
|
||||
|
||||
from agentlightning.llm_proxy import ModelConfig
|
||||
from agentlightning.types import Attempt, Dataset, Rollout, RolloutStatus, Span
|
||||
|
||||
from .base import BaseAlgorithm
|
||||
from .base import Algorithm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["FastAlgorithm", "Baseline"]
|
||||
|
||||
|
||||
class FastAlgorithm(BaseAlgorithm):
|
||||
"""Algorithm that can run fast and qualify for dev mode.
|
||||
class FastAlgorithm(Algorithm):
|
||||
"""Base class for lightweight algorithms optimised for developer workflows.
|
||||
|
||||
Fast algorithms enable agent developers to quickly iterate on agent development
|
||||
without waiting for a long training to complete.
|
||||
Fast algorithms prioritise short feedback loops so an agent developer can run
|
||||
small-scale experiments without waiting for long-running training jobs to
|
||||
finish.
|
||||
"""
|
||||
|
||||
|
||||
@@ -30,24 +30,38 @@ def _timestamp_to_iso_str(timestamp: float) -> str:
|
||||
|
||||
|
||||
class Baseline(FastAlgorithm):
|
||||
"""A dummy implementation of algorithm interface that puts all dataset into the queue, and waits for all rollouts to complete.
|
||||
"""Reference implementation that streams the full dataset through the rollout queue.
|
||||
|
||||
Logs all collected spans and rewards.
|
||||
The baseline algorithm batches task submissions, waits for each rollout to
|
||||
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.
|
||||
|
||||
Args:
|
||||
model_list: Optional list of models to load into the llm proxy.
|
||||
If both model_list and llm_proxy is provided, llm_proxy will be launched.
|
||||
Not implemented yet.
|
||||
n_epochs: Number of epochs to run through the dev dataset.
|
||||
train_split: Fraction of dev dataset to use for training vs validation. Must be between 0 and 1.
|
||||
polling_interval: Time interval (in seconds) to poll the store for queue length and for completed rollouts.
|
||||
max_queue_length: Maximum number of rollouts to keep in the queue at any time.
|
||||
n_epochs: Number of dataset passes to execute for both the train and val
|
||||
splits during developer experiments.
|
||||
train_split: Fraction of the concatenated dataset to treat as training
|
||||
data. Must be strictly between 0 and 1.
|
||||
polling_interval: Interval, in seconds, to poll the store for queue
|
||||
depth and rollout completion.
|
||||
max_queue_length: Number of rollouts allowed to wait in the queue before
|
||||
throttling additional submissions.
|
||||
span_verbosity: Level of detail to include when logging span metadata.
|
||||
|
||||
Raises:
|
||||
ValueError: If `train_split` falls outside the `(0, 1)` interval.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from agentlightning.algorithm.fast import Baseline
|
||||
|
||||
algorithm = Baseline(n_epochs=2, train_split=0.8, span_verbosity="key_values")
|
||||
trainer.fit(algorithm, train_dataset=my_train, val_dataset=my_val)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model_list: Optional[List[ModelConfig]] = None,
|
||||
n_epochs: int = 1,
|
||||
train_split: float = 0.5,
|
||||
polling_interval: float = 5.0,
|
||||
@@ -66,6 +80,7 @@ class Baseline(FastAlgorithm):
|
||||
self._finished_rollout_count = 0
|
||||
|
||||
def _span_to_string(self, rollout_id: str, attempt: Attempt, span: Span) -> str:
|
||||
"""Format a span for logging based on the configured verbosity."""
|
||||
if self.span_verbosity == "none":
|
||||
return ""
|
||||
|
||||
@@ -85,6 +100,7 @@ class Baseline(FastAlgorithm):
|
||||
return msg
|
||||
|
||||
async def _handle_rollout_finish(self, rollout: Rollout) -> None:
|
||||
"""Log attempt metadata and emit adapted traces when a rollout ends."""
|
||||
store = self.get_store()
|
||||
|
||||
rollout_id = rollout.rollout_id
|
||||
@@ -97,7 +113,12 @@ class Baseline(FastAlgorithm):
|
||||
attempts = await store.query_attempts(rollout_id)
|
||||
for attempt in attempts:
|
||||
logger.info(
|
||||
f"[Rollout {rollout_id} | Attempt {attempt.sequence_id}] ID: {attempt.attempt_id}. Status: {attempt.status}. Worker: {attempt.worker_id}"
|
||||
"[Rollout %s | Attempt %s] ID: %s. Status: %s. Worker: %s",
|
||||
rollout_id,
|
||||
attempt.sequence_id,
|
||||
attempt.attempt_id,
|
||||
attempt.status,
|
||||
attempt.worker_id,
|
||||
)
|
||||
spans = await store.query_spans(rollout_id=rollout_id)
|
||||
for span in spans:
|
||||
@@ -107,19 +128,22 @@ class Baseline(FastAlgorithm):
|
||||
# Attempts to adapt the spans using the adapter if provided
|
||||
try:
|
||||
adapter = self.get_adapter()
|
||||
except ValueError:
|
||||
logger.warning("No adapter set for MockAlgorithm. Skipping trace adaptation.")
|
||||
adapter = None
|
||||
if adapter is not None:
|
||||
spans = await store.query_spans(rollout_id=rollout_id, attempt_id="latest")
|
||||
transformed_data = adapter.adapt(spans)
|
||||
logger.info(f"[Rollout {rollout_id}] Adapted data: {transformed_data}")
|
||||
except ValueError:
|
||||
logger.warning("No adapter set for MockAlgorithm. Skipping trace adaptation.")
|
||||
|
||||
async def _enqueue_rollouts(
|
||||
self, dataset: Dataset[Any], train_indices: List[int], val_indices: List[int], resources_id: str
|
||||
) -> None:
|
||||
"""Submit rollouts while respecting the maximum queue length."""
|
||||
store = self.get_store()
|
||||
|
||||
for index in train_indices + val_indices:
|
||||
queuing_rollouts = await store.query_rollouts(status=["queuing", "requeuing"])
|
||||
queuing_rollouts = await store.query_rollouts(status_in=["queuing", "requeuing"])
|
||||
if len(queuing_rollouts) <= 1:
|
||||
# Only enqueue a new rollout when there is at most 1 rollout in the queue.
|
||||
sample = dataset[index]
|
||||
@@ -129,6 +153,7 @@ class Baseline(FastAlgorithm):
|
||||
await asyncio.sleep(self.polling_interval)
|
||||
|
||||
async def _harvest_rollout_spans(self, rollout_id: str):
|
||||
"""Poll rollout status updates until completion and log transitions."""
|
||||
store = self.get_store()
|
||||
last_status: Optional[RolloutStatus] = None
|
||||
|
||||
@@ -160,11 +185,12 @@ class Baseline(FastAlgorithm):
|
||||
train_dataset: Optional[Dataset[Any]] = None,
|
||||
val_dataset: Optional[Dataset[Any]] = None,
|
||||
) -> None:
|
||||
"""Execute the baseline loop across the provided datasets."""
|
||||
train_dataset_length = len(train_dataset) if train_dataset is not None else 0
|
||||
val_dataset_length = len(val_dataset) if val_dataset is not None else 0
|
||||
if train_dataset_length == 0 and val_dataset_length == 0:
|
||||
logger.error(
|
||||
"MockAlgorithm requires at least a train_dataset or val_dataset to run. No train_dataset or val_dataset is provided. Exiting."
|
||||
"MockAlgorithm requires at least one dataset. Provide train_dataset or val_dataset before running."
|
||||
)
|
||||
return
|
||||
|
||||
@@ -173,6 +199,8 @@ class Baseline(FastAlgorithm):
|
||||
]
|
||||
train_indices = list(range(0, train_dataset_length))
|
||||
val_indices = list(range(train_dataset_length, train_dataset_length + val_dataset_length))
|
||||
logger.debug(f"Train indices: {train_indices}")
|
||||
logger.debug(f"Val indices: {val_indices}")
|
||||
|
||||
store = self.get_store()
|
||||
|
||||
@@ -190,19 +218,24 @@ class Baseline(FastAlgorithm):
|
||||
harvest_tasks: List[asyncio.Task[None]] = []
|
||||
logger.info(f"Proceeding epoch {epoch + 1}/{self.n_epochs}.")
|
||||
for index in train_indices + val_indices:
|
||||
queuing_rollouts = await store.query_rollouts(status=["queuing", "requeuing"])
|
||||
if len(queuing_rollouts) <= self.max_queue_length:
|
||||
# Only enqueue a new rollout when there is at most "max_queue_length" rollout in the queue.
|
||||
sample = concatenated_dataset[index]
|
||||
mode = "train" if index in train_indices else "val"
|
||||
rollout = await store.enqueue_rollout(input=sample, mode=mode, resources_id=resources_id)
|
||||
harvest_tasks.append(asyncio.create_task(self._harvest_rollout_spans(rollout.rollout_id)))
|
||||
logger.info(f"Enqueued rollout {rollout.rollout_id} in {mode} mode with sample: {sample}")
|
||||
else:
|
||||
# Sleep a bit and try again later.
|
||||
await asyncio.sleep(self.polling_interval)
|
||||
logger.info(
|
||||
f"Processing index {index}. {len(train_indices)} train indices and {len(val_indices)} val indices in total."
|
||||
)
|
||||
while True:
|
||||
queuing_rollouts = await store.query_rollouts(status_in=["queuing", "requeuing"])
|
||||
if len(queuing_rollouts) <= self.max_queue_length:
|
||||
# Only enqueue a new rollout when there is at most "max_queue_length" rollout in the queue.
|
||||
sample = concatenated_dataset[index]
|
||||
mode = "train" if index in train_indices else "val"
|
||||
rollout = await store.enqueue_rollout(input=sample, mode=mode, resources_id=resources_id)
|
||||
harvest_tasks.append(asyncio.create_task(self._harvest_rollout_spans(rollout.rollout_id)))
|
||||
logger.info(f"Enqueued rollout {rollout.rollout_id} in {mode} mode with sample: {sample}")
|
||||
break
|
||||
else:
|
||||
# Sleep a bit and try again later.
|
||||
await asyncio.sleep(self.polling_interval)
|
||||
|
||||
# Wait for all harvest tasks to complete
|
||||
print(f"Waiting for {len(harvest_tasks)} harvest tasks to complete...")
|
||||
logger.info(f"Waiting for {len(harvest_tasks)} harvest tasks to complete...")
|
||||
if len(harvest_tasks) > 0:
|
||||
await asyncio.gather(*harvest_tasks)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import random
|
||||
from typing import Iterator, List, Sequence, TypeVar
|
||||
|
||||
from agentlightning.types import Dataset
|
||||
|
||||
T_task = TypeVar("T_task")
|
||||
|
||||
|
||||
def batch_iter_over_dataset(dataset: Dataset[T_task], batch_size: int) -> Iterator[Sequence[T_task]]:
|
||||
"""
|
||||
Create an infinite iterator that yields batches from the dataset.
|
||||
|
||||
When batch_size >= dataset size, yields the entire shuffled dataset repeatedly.
|
||||
When batch_size < dataset size, yields batches of the specified size, reshuffling
|
||||
after each complete pass through the dataset.
|
||||
|
||||
Args:
|
||||
dataset: The dataset to iterate over.
|
||||
batch_size: The desired batch size.
|
||||
|
||||
Yields:
|
||||
Sequences of tasks from the dataset. Each task appears at most once per epoch.
|
||||
"""
|
||||
if batch_size >= len(dataset):
|
||||
while True:
|
||||
dataset_copy = [dataset[i] for i in range(len(dataset))]
|
||||
random.shuffle(dataset_copy)
|
||||
yield dataset_copy
|
||||
|
||||
else:
|
||||
current_batch: List[int] = []
|
||||
while True:
|
||||
indices = list(range(len(dataset)))
|
||||
random.shuffle(indices)
|
||||
for index in indices:
|
||||
if index in current_batch:
|
||||
continue
|
||||
current_batch.append(index)
|
||||
if len(current_batch) == batch_size:
|
||||
yield [dataset[index] for index in current_batch]
|
||||
current_batch = []
|
||||
@@ -5,23 +5,89 @@ from typing import Any, Optional
|
||||
from hydra import compose, initialize
|
||||
from omegaconf import OmegaConf
|
||||
|
||||
from agentlightning.algorithm.base import BaseAlgorithm
|
||||
from agentlightning.algorithm.base import Algorithm
|
||||
from agentlightning.client import AgentLightningClient
|
||||
from agentlightning.types import Dataset
|
||||
from agentlightning.verl.entrypoint import run_ppo # type: ignore
|
||||
|
||||
|
||||
class VERL(BaseAlgorithm):
|
||||
"""Algorithm leveraging VERL as the backend framework.
|
||||
class VERL(Algorithm):
|
||||
"""VERL-powered algorithm that delegates training to the VERL PPO runner.
|
||||
|
||||
**Note on Customization:**
|
||||
|
||||
At present, we recommend copying the source code from VERL and modifying it as needed to suit your requirements.
|
||||
Native support for customizing training logic will be provided in future releases.
|
||||
!!! warning
|
||||
Advanced customisation currently requires copying the VERL source and
|
||||
modifying it directly. Native hooks for overriding training behaviour
|
||||
will land in a future release.
|
||||
|
||||
Args:
|
||||
config: The VERL configuration, matching what is typically provided when running VERL via the command line.
|
||||
This config will be merged with VERL's base configuration and processed by Hydra.
|
||||
config: Dictionary mirroring the overrides passed to the VERL CLI. The
|
||||
overrides are merged with VERL's packaged defaults via Hydra before
|
||||
launching training.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from agentlightning.algorithm.verl import VERL
|
||||
|
||||
algorithm = VERL(
|
||||
config={
|
||||
"algorithm": {
|
||||
"adv_estimator": "grpo",
|
||||
"use_kl_in_reward": False,
|
||||
},
|
||||
"data": {
|
||||
"train_batch_size": 32,
|
||||
"max_prompt_length": 4096,
|
||||
"max_response_length": 2048,
|
||||
},
|
||||
"actor_rollout_ref": {
|
||||
"rollout": {
|
||||
"tensor_model_parallel_size": 1,
|
||||
"n": 4,
|
||||
"log_prob_micro_batch_size_per_gpu": 4,
|
||||
"multi_turn": {"format": "hermes"},
|
||||
"name": "vllm",
|
||||
"gpu_memory_utilization": 0.6,
|
||||
},
|
||||
"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": 8,
|
||||
"fsdp_config": {"param_offload": True},
|
||||
},
|
||||
"model": {
|
||||
"path": "Qwen/Qwen2.5-1.5B-Instruct",
|
||||
"use_remove_padding": True,
|
||||
"enable_gradient_checkpointing": True,
|
||||
},
|
||||
},
|
||||
"trainer": {
|
||||
"n_gpus_per_node": 1,
|
||||
"val_before_train": True,
|
||||
"critic_warmup": 0,
|
||||
"logger": ["console", "wandb"],
|
||||
"project_name": "AgentLightning",
|
||||
"experiment_name": "calc_x",
|
||||
"nnodes": 1,
|
||||
"save_freq": 64,
|
||||
"test_freq": 32,
|
||||
"total_epochs": 2,
|
||||
},
|
||||
}
|
||||
)
|
||||
trainer.fit(algorithm, train_dataset=my_train_dataset)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, Any]):
|
||||
@@ -33,6 +99,8 @@ class VERL(BaseAlgorithm):
|
||||
|
||||
# Merge your dict overrides
|
||||
override_conf = OmegaConf.create(config)
|
||||
# Allow adding new fields
|
||||
OmegaConf.set_struct(base_cfg, False)
|
||||
self.config = OmegaConf.merge(base_cfg, override_conf)
|
||||
|
||||
def run(
|
||||
@@ -40,6 +108,17 @@ class VERL(BaseAlgorithm):
|
||||
train_dataset: Optional[Dataset[Any]] = None,
|
||||
val_dataset: Optional[Dataset[Any]] = None,
|
||||
) -> None:
|
||||
"""Launch the VERL PPO entrypoint with the configured runtime context.
|
||||
|
||||
Args:
|
||||
train_dataset: Optional dataset forwarded to VERL for training.
|
||||
val_dataset: Optional dataset forwarded to VERL for evaluation.
|
||||
|
||||
Raises:
|
||||
ValueError: If required dependencies such as the store, LLM proxy, or
|
||||
adapter have been garbage-collected when using the V1 execution
|
||||
mode.
|
||||
"""
|
||||
try:
|
||||
store = self.get_store()
|
||||
except Exception:
|
||||
@@ -66,5 +145,10 @@ class VERL(BaseAlgorithm):
|
||||
)
|
||||
|
||||
def get_client(self) -> AgentLightningClient:
|
||||
"""Create a client bound to the VERL-managed Agent Lightning server.
|
||||
|
||||
Deprecated:
|
||||
Since v0.2.
|
||||
"""
|
||||
port = self.config.agentlightning.port
|
||||
return AgentLightningClient(endpoint=f"http://localhost:{port}")
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
from typing import Iterable
|
||||
|
||||
from agentlightning.instrumentation.agentops import AgentOpsServerManager
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Start AgentOps server")
|
||||
parser.add_argument("--daemon", action="store_true", help="Run server as a daemon")
|
||||
parser.add_argument("--port", type=int, default=8002, help="Port to run the server on")
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
|
||||
manager = AgentOpsServerManager(daemon=args.daemon, port=args.port)
|
||||
try:
|
||||
manager.start()
|
||||
# Wait forever
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
manager.stop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -6,23 +6,92 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
from agentlightning.logging import configure_logger
|
||||
from agentlightning import setup_logging
|
||||
from agentlightning.store.client_server import LightningStoreServer
|
||||
from agentlightning.store.memory import InMemoryLightningStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Run a LightningStore server")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host to bind the server to")
|
||||
parser.add_argument("--port", type=int, default=4747, help="Port to run the server on")
|
||||
parser.add_argument(
|
||||
"--cors-origin",
|
||||
dest="cors_origins",
|
||||
action="append",
|
||||
help="Allowed CORS origin. Repeat for multiple origins. Use '*' to allow all origins.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
default="INFO",
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||
help="Configure the logging level for the store.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prometheus",
|
||||
action="store_true",
|
||||
help="Enable Prometheus metrics.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--n-workers",
|
||||
default=1,
|
||||
type=int,
|
||||
help=(
|
||||
"Number of workers to run in the server. When it's greater than 1, the server will be run using `mp` launch mode. "
|
||||
"Only applicable for zero-copy stores such as MongoDB backend."
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
choices=["memory", "mongo"],
|
||||
default="memory",
|
||||
help="Backend to use for the store.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mongo-uri",
|
||||
default="mongodb://localhost:27017/?replicaSet=rs0",
|
||||
help="MongoDB URI to use for the store. Applicable only if --backend is 'mongo'.",
|
||||
)
|
||||
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
|
||||
configure_logger()
|
||||
setup_logging(args.log_level)
|
||||
|
||||
store = InMemoryLightningStore()
|
||||
server = LightningStoreServer(store, host="0.0.0.0", port=args.port)
|
||||
asyncio.run(server.run_forever())
|
||||
if args.backend == "memory":
|
||||
store = InMemoryLightningStore()
|
||||
elif args.backend == "mongo":
|
||||
from agentlightning.store.mongo import MongoLightningStore
|
||||
|
||||
store = MongoLightningStore(client=args.mongo_uri)
|
||||
else:
|
||||
raise ValueError(f"Invalid backend: {args.backend}")
|
||||
|
||||
if args.n_workers > 1:
|
||||
logger.info(f"Running the server using `mp` launch mode with {args.n_workers} workers.")
|
||||
launch_mode = "mp"
|
||||
else:
|
||||
logger.info("Running the server using `asyncio` launch mode.")
|
||||
launch_mode = "asyncio"
|
||||
server = LightningStoreServer(
|
||||
store,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
cors_allow_origins=args.cors_origins,
|
||||
launch_mode=launch_mode,
|
||||
prometheus=args.prometheus,
|
||||
n_workers=args.n_workers,
|
||||
)
|
||||
try:
|
||||
asyncio.run(server.run_forever())
|
||||
except RuntimeError as exc:
|
||||
logger.error("LightningStore server failed to start: %s", exc, exc_info=True)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+96
-61
@@ -1,6 +1,12 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Legacy client for interacting with a legacy Agent Lightning server."""
|
||||
"""Utilities for interacting with legacy Agent Lightning servers.
|
||||
|
||||
This module contains compatibility shims that speak the deprecated HTTP
|
||||
interface used by older Agent Lightning deployments. Modern code should prefer
|
||||
the store-based APIs exposed by `agentlightning.store`, but keeping these
|
||||
clients available makes it easier to migrate existing workflows incrementally.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
@@ -18,13 +24,24 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentLightningClient:
|
||||
"""
|
||||
Client for interacting with a version-aware Agent Lightning Server.
|
||||
"""Client wrapper for the legacy version-aware Agent Lightning server.
|
||||
|
||||
This client handles polling for tasks, fetching specific versions of resources
|
||||
(like model configurations), and posting completed rollouts back to the server.
|
||||
It provides both synchronous and asynchronous methods for these operations and
|
||||
includes a cache for resources.
|
||||
The client exposes synchronous and asynchronous helpers for polling tasks,
|
||||
retrieving resource bundles, and submitting rollouts. It also maintains a
|
||||
simple in-memory cache keyed by the server-provided resource identifier to
|
||||
avoid redundant network requests.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
[`AgentLightningClient`][agentlightning.client.AgentLightningClient] is part of
|
||||
the legacy client/server stack. New code should rely on the store-based APIs
|
||||
implemented in `agentlightning.store`.
|
||||
|
||||
Attributes:
|
||||
endpoint: Base URL of the Agent Lightning server.
|
||||
poll_interval: Delay in seconds between polling attempts when no task is
|
||||
available.
|
||||
timeout: Timeout in seconds applied to HTTP requests.
|
||||
task_count: Number of tasks claimed during the lifetime of this client.
|
||||
"""
|
||||
|
||||
_next_task_uri = "/task"
|
||||
@@ -33,12 +50,12 @@ class AgentLightningClient:
|
||||
_report_rollout_uri = "/rollout"
|
||||
|
||||
def __init__(self, endpoint: str, poll_interval: float = 5.0, timeout: float = 10.0):
|
||||
"""Initializes the AgentLightningClient.
|
||||
"""Initialize the client.
|
||||
|
||||
Args:
|
||||
endpoint: The root URL of the Agent Lightning server.
|
||||
poll_interval: The interval in seconds to wait between polling for new tasks.
|
||||
timeout: The timeout in seconds for HTTP requests.
|
||||
endpoint: Root URL of the Agent Lightning server.
|
||||
poll_interval: Seconds to wait between polling attempts.
|
||||
timeout: Seconds before a request to the server is considered timed out.
|
||||
"""
|
||||
warnings.warn(
|
||||
"AgentLightningClient is deprecated. Please use LightningStoreClient instead.", DeprecationWarning
|
||||
@@ -51,13 +68,13 @@ class AgentLightningClient:
|
||||
self._default_headers = {"X-AgentLightning-Client": "true"}
|
||||
|
||||
async def _request_json_async(self, url: str) -> Optional[Dict[str, Any]]:
|
||||
"""Makes an async GET request to the specified URL and returns the JSON response.
|
||||
"""Perform an asynchronous ``GET`` request and parse the JSON payload.
|
||||
|
||||
Args:
|
||||
url: The URL to request.
|
||||
url: Fully qualified URL to query.
|
||||
|
||||
Returns:
|
||||
The JSON response as a dictionary or None if the request fails.
|
||||
Parsed JSON body as a dictionary if the request succeeds; otherwise ``None``.
|
||||
"""
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
@@ -70,14 +87,14 @@ class AgentLightningClient:
|
||||
return None
|
||||
|
||||
async def _post_json_async(self, url: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Makes an async POST request with a JSON payload.
|
||||
"""Perform an asynchronous ``POST`` request with a JSON body.
|
||||
|
||||
Args:
|
||||
url: The URL to post to.
|
||||
payload: The dictionary data to send as JSON.
|
||||
url: Fully qualified URL that accepts the payload.
|
||||
payload: Dictionary that will be serialized and sent as JSON.
|
||||
|
||||
Returns:
|
||||
The JSON response as a dictionary or None if the request fails.
|
||||
Parsed JSON body as a dictionary if the request succeeds; otherwise ``None``.
|
||||
"""
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
@@ -90,10 +107,11 @@ class AgentLightningClient:
|
||||
return None
|
||||
|
||||
async def poll_next_task_async(self) -> Optional[Task]:
|
||||
"""Polls the server asynchronously for the next task until one is available.
|
||||
"""Poll the server asynchronously until a task becomes available.
|
||||
|
||||
Returns:
|
||||
A Task object containing the task details.
|
||||
The next [`Task`][agentlightning.Task] exposed by the server,
|
||||
or ``None`` if polling fails.
|
||||
"""
|
||||
url = urllib.parse.urljoin(self.endpoint, self._next_task_uri)
|
||||
while True:
|
||||
@@ -108,13 +126,15 @@ class AgentLightningClient:
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
|
||||
async def get_resources_by_id_async(self, resource_id: str) -> Optional[ResourcesUpdate]:
|
||||
"""Fetches a specific version of resources by its ID, using a cache.
|
||||
"""Fetch a specific resource bundle by identifier.
|
||||
|
||||
Args:
|
||||
resource_id: The ID of the resources to fetch, usually from a Task's metadata.
|
||||
resource_id: Identifier sourced from the task metadata.
|
||||
|
||||
Returns:
|
||||
A ResourcesUpdate object containing the versioned resources, or None if not found.
|
||||
Cached or freshly downloaded
|
||||
[`ResourcesUpdate`][agentlightning.ResourcesUpdate], or
|
||||
``None`` when the server returns an error.
|
||||
"""
|
||||
if resource_id in self._resource_cache:
|
||||
logger.debug(f"Found resources '{resource_id}' in cache.")
|
||||
@@ -130,10 +150,11 @@ class AgentLightningClient:
|
||||
return None
|
||||
|
||||
async def get_latest_resources_async(self) -> Optional[ResourcesUpdate]:
|
||||
"""Fetches the latest available resources from the server.
|
||||
"""Fetch the most recent resource bundle advertised by the server.
|
||||
|
||||
Returns:
|
||||
A ResourcesUpdate object containing the latest resources.
|
||||
[`ResourcesUpdate`][agentlightning.ResourcesUpdate] for the
|
||||
newest version, or ``None`` when unavailable.
|
||||
"""
|
||||
url = urllib.parse.urljoin(self.endpoint, self._latest_resources_uri)
|
||||
response = await self._request_json_async(url)
|
||||
@@ -145,26 +166,26 @@ class AgentLightningClient:
|
||||
return None
|
||||
|
||||
async def post_rollout_async(self, rollout: RolloutLegacy) -> Optional[Dict[str, Any]]:
|
||||
"""Posts a completed rollout to the server asynchronously.
|
||||
"""Submit a completed rollout back to the server.
|
||||
|
||||
Args:
|
||||
rollout: A Rollout object containing the results of a task.
|
||||
rollout: Legacy rollout payload produced by the executor.
|
||||
|
||||
Returns:
|
||||
The server's JSON response as a dictionary.
|
||||
Parsed JSON response returned by the server, or ``None`` when the request fails.
|
||||
"""
|
||||
url = urllib.parse.urljoin(self.endpoint, self._report_rollout_uri)
|
||||
payload = rollout.model_dump(mode="json")
|
||||
return await self._post_json_async(url, payload)
|
||||
|
||||
def _request_json(self, url: str) -> Optional[Dict[str, Any]]:
|
||||
"""Makes a sync GET request to the specified URL and returns the JSON response.
|
||||
"""Perform a blocking ``GET`` request and parse the JSON payload.
|
||||
|
||||
Args:
|
||||
url: The URL to request.
|
||||
url: Fully qualified URL to query.
|
||||
|
||||
Returns:
|
||||
The JSON response as a dictionary or None if the request fails.
|
||||
Parsed JSON body as a dictionary if the request succeeds; otherwise ``None``.
|
||||
"""
|
||||
try:
|
||||
response = requests.get(url, timeout=self.timeout, headers=self._default_headers)
|
||||
@@ -175,14 +196,14 @@ class AgentLightningClient:
|
||||
return None
|
||||
|
||||
def _post_json(self, url: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Makes a sync POST request with a JSON payload.
|
||||
"""Perform a blocking ``POST`` request with a JSON payload.
|
||||
|
||||
Args:
|
||||
url: The URL to post to.
|
||||
payload: The dictionary data to send as JSON.
|
||||
url: Fully qualified URL that accepts the payload.
|
||||
payload: Dictionary that will be serialized and sent as JSON.
|
||||
|
||||
Returns:
|
||||
The JSON response as a dictionary or None if the request fails.
|
||||
Parsed JSON body as a dictionary if the request succeeds; otherwise ``None``.
|
||||
"""
|
||||
try:
|
||||
response = requests.post(url, json=payload, timeout=self.timeout, headers=self._default_headers)
|
||||
@@ -193,10 +214,11 @@ class AgentLightningClient:
|
||||
return None
|
||||
|
||||
def poll_next_task(self) -> Optional[Task]:
|
||||
"""Polls the server synchronously for the next task until one is available.
|
||||
"""Poll the server synchronously until a task becomes available.
|
||||
|
||||
Returns:
|
||||
A Task object containing the task details, including the required `resources_id`.
|
||||
The next [`Task`][agentlightning.Task] available for execution, or
|
||||
``None`` if polling fails.
|
||||
"""
|
||||
url = urllib.parse.urljoin(self.endpoint, self._next_task_uri)
|
||||
while True:
|
||||
@@ -211,13 +233,15 @@ class AgentLightningClient:
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
def get_resources_by_id(self, resource_id: str) -> Optional[ResourcesUpdate]:
|
||||
"""Fetches a specific version of resources by its ID synchronously, using a cache.
|
||||
"""Fetch a specific resource bundle by identifier.
|
||||
|
||||
Args:
|
||||
resource_id: The ID of the resources to fetch, usually from a Task's metadata.
|
||||
resource_id: Identifier sourced from the task metadata.
|
||||
|
||||
Returns:
|
||||
A ResourcesUpdate object containing the versioned resources, or None if not found.
|
||||
Cached or freshly downloaded
|
||||
[`ResourcesUpdate`][agentlightning.ResourcesUpdate], or
|
||||
``None`` when the server returns an error.
|
||||
"""
|
||||
if resource_id in self._resource_cache:
|
||||
logger.debug(f"Found resources '{resource_id}' in cache.")
|
||||
@@ -233,10 +257,11 @@ class AgentLightningClient:
|
||||
return None
|
||||
|
||||
def get_latest_resources(self) -> Optional[ResourcesUpdate]:
|
||||
"""Fetches the latest available resources from the server synchronously.
|
||||
"""Fetch the most recent resource bundle advertised by the server.
|
||||
|
||||
Returns:
|
||||
A ResourcesUpdate object containing the latest resources.
|
||||
[`ResourcesUpdate`][agentlightning.ResourcesUpdate] for the
|
||||
newest version, or ``None`` when unavailable.
|
||||
"""
|
||||
url = urllib.parse.urljoin(self.endpoint, self._latest_resources_uri)
|
||||
response = self._request_json(url)
|
||||
@@ -247,13 +272,13 @@ class AgentLightningClient:
|
||||
return None
|
||||
|
||||
def post_rollout(self, rollout: RolloutLegacy) -> Optional[Dict[str, Any]]:
|
||||
"""Posts a completed rollout to the server synchronously.
|
||||
"""Submit a completed rollout back to the server.
|
||||
|
||||
Args:
|
||||
rollout: A Rollout object containing the results of a task.
|
||||
rollout: Legacy rollout payload produced by the executor.
|
||||
|
||||
Returns:
|
||||
The server's JSON response as a dictionary.
|
||||
Parsed JSON response returned by the server, or ``None`` when the request fails.
|
||||
"""
|
||||
url = urllib.parse.urljoin(self.endpoint, self._report_rollout_uri)
|
||||
payload = rollout.model_dump(mode="json")
|
||||
@@ -261,14 +286,16 @@ class AgentLightningClient:
|
||||
|
||||
|
||||
class DevTaskLoader(AgentLightningClient):
|
||||
"""A local task manager for development that provides sample tasks and resources.
|
||||
"""In-memory task loader used for development and integration tests.
|
||||
|
||||
This client mocks the server APIs by maintaining a local queue of tasks and resources
|
||||
within the same process. It's designed for development, testing, and scenarios where
|
||||
a full Agent Lightning server is not needed.
|
||||
The loader mimics the behavior of the legacy HTTP server by storing tasks and
|
||||
resources locally. Polling methods simply iterate over the provided collection,
|
||||
allowing rapid iteration without provisioning any external infrastructure.
|
||||
|
||||
The DevTaskLoader overrides the polling and resource fetching methods to return data
|
||||
from local collections instead of making HTTP requests to a remote server.
|
||||
!!! warning "Deprecated"
|
||||
|
||||
[`DevTaskLoader`][agentlightning.client.DevTaskLoader] is a compatibility shim.
|
||||
Prefer [`Trainer.dev`][agentlightning.Trainer.dev] for new code.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -277,12 +304,17 @@ class DevTaskLoader(AgentLightningClient):
|
||||
resources: Union[NamedResources, ResourcesUpdate],
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Initializes the DevTaskLoader with pre-defined tasks and resources.
|
||||
"""Initialize the loader with predefined tasks and resources.
|
||||
|
||||
Args:
|
||||
tasks: Either a List of TaskInput objects or a List of Task objects.
|
||||
resources: Either NamedResources or ResourcesUpdate object.
|
||||
**kwargs: Additional arguments passed to the parent AgentLightningClient.
|
||||
tasks: Sequence of task inputs or preconstructed tasks that will be served in
|
||||
order.
|
||||
resources: Static resources returned for any `resources_id` query.
|
||||
**kwargs: Additional keyword arguments forwarded to the parent client.
|
||||
|
||||
Raises:
|
||||
ValueError: If no tasks are provided or both [`Task`][agentlightning.Task]
|
||||
and [`TaskInput`][agentlightning.TaskInput] instances are mixed.
|
||||
"""
|
||||
warnings.warn("DevTaskLoader is deprecated. Please use Trainer.dev instead.", DeprecationWarning)
|
||||
super().__init__(endpoint="local://", **kwargs)
|
||||
@@ -300,24 +332,27 @@ class DevTaskLoader(AgentLightningClient):
|
||||
if isinstance(resources, ResourcesUpdate):
|
||||
self._resources_update = resources
|
||||
else:
|
||||
self._resources_update = ResourcesUpdate(resources_id="local", resources=resources)
|
||||
self._resources_update = ResourcesUpdate(
|
||||
resources_id="local", resources=resources, create_time=time.time(), update_time=time.time(), version=1
|
||||
)
|
||||
|
||||
# Store rollouts posted back to the loader for easy debugging of local runs
|
||||
self._rollouts: List[RolloutLegacy] = []
|
||||
|
||||
@property
|
||||
def rollouts(self) -> List[RolloutLegacy]:
|
||||
"""Return rollouts that have been posted back to the loader."""
|
||||
"""Return the rollouts posted back to the loader during development runs."""
|
||||
return self._rollouts
|
||||
|
||||
def poll_next_task(self) -> Optional[Task]:
|
||||
"""Returns the next task from the local queue.
|
||||
"""Return the next task from the local queue.
|
||||
|
||||
If tasks are TaskInput objects, assembles them into Task objects.
|
||||
If tasks are already Task objects, returns them directly.
|
||||
If [`TaskInput`][agentlightning.TaskInput] instances were provided,
|
||||
they are converted into [`Task`][agentlightning.Task] objects on the
|
||||
fly. Otherwise, the preconstructed tasks are returned in sequence.
|
||||
|
||||
Returns:
|
||||
The next Task object from the local task list.
|
||||
Next task to execute.
|
||||
"""
|
||||
if self._task_index >= len(self._tasks):
|
||||
self._task_index = 0
|
||||
|
||||
@@ -83,12 +83,17 @@ def _str_to_bool(v: str) -> bool:
|
||||
|
||||
|
||||
def _get_param_type_details(param_annotation: Any) -> Tuple[Any, bool, bool]:
|
||||
"""
|
||||
Determines the core type, if it's Optional, and if it's a List.
|
||||
Returns: (core_type, is_optional, is_list)
|
||||
- For Optional[T]: (T, True, is_list_status_of_T)
|
||||
- For List[T]: (List[T], is_optional_status_of_List, True)
|
||||
- For Optional[List[T]]: (List[T], True, True)
|
||||
"""Normalize an annotation into its core type, optionality, and list status.
|
||||
|
||||
Args:
|
||||
param_annotation: The annotation to inspect.
|
||||
|
||||
Returns:
|
||||
A tuple ``(core_type, is_optional, is_list)`` describing the normalized type.
|
||||
|
||||
- For ``Optional[T]`` → ``(T, True, is_list_status_of_T)``
|
||||
- For ``List[T]`` → ``(List[T], is_optional_status_of_List, True)``
|
||||
- For ``Optional[List[T]]`` → ``(List[T], True, True)``
|
||||
"""
|
||||
is_optional = False
|
||||
is_list = False
|
||||
|
||||
@@ -13,7 +13,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def emit_exception(exception: BaseException) -> None:
|
||||
"""Emit an exception as a span."""
|
||||
"""Record an exception with OpenTelemetry metadata.
|
||||
|
||||
Args:
|
||||
exception: Raised exception instance to serialize into telemetry attributes.
|
||||
|
||||
!!! note
|
||||
The helper validates its input. Non-exception values are ignored to prevent
|
||||
noisy telemetry and indicate programming mistakes via the logger.
|
||||
"""
|
||||
if not isinstance(exception, BaseException): # type: ignore
|
||||
logger.error(f"Expected an BaseException instance, got: {type(exception)}. Skip emit_exception.")
|
||||
return
|
||||
|
||||
@@ -10,10 +10,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def emit_message(message: str) -> None:
|
||||
"""Emit a string message as a span.
|
||||
"""Emit a textual message as an OpenTelemetry span.
|
||||
|
||||
OpenTelemetry has a dedicated design of logs by design, but we can also use spans to emit messages.
|
||||
So that it can all be unified in the data store and analyzed together.
|
||||
Args:
|
||||
message: Human readable message to attach as a span attribute.
|
||||
|
||||
!!! note
|
||||
OpenTelemetry distinguishes between logs and spans. Emitting the message as a
|
||||
span keeps all Agent Lightning telemetry in a single data store for analysis.
|
||||
"""
|
||||
if not isinstance(message, str): # type: ignore
|
||||
logger.error(f"Message must be a string, got: {type(message)}. Skip emit_message.")
|
||||
|
||||
@@ -12,7 +12,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def emit_object(object: Any) -> None:
|
||||
"""Emit any object as a span. Make sure the object is JSON serializable."""
|
||||
"""Emit an object's serialized representation as an OpenTelemetry span.
|
||||
|
||||
Args:
|
||||
object: Data structure to encode as JSON and attach to the span payload.
|
||||
|
||||
!!! note
|
||||
The payload must be JSON serializable. Non-serializable objects are ignored and
|
||||
an error is logged to aid debugging.
|
||||
"""
|
||||
try:
|
||||
serialized = json.dumps(object)
|
||||
except (TypeError, ValueError):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Helpers for emitting reward spans and integrating with AgentOps telemetry."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
@@ -47,20 +49,29 @@ FnType = TypeVar("FnType", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def _agentops_initialized() -> bool:
|
||||
"""Check if AgentOps is initialized in the current context."""
|
||||
"""Return `True` when the AgentOps client has been configured."""
|
||||
return agentops.get_client().initialized
|
||||
|
||||
|
||||
def reward(fn: FnType) -> FnType:
|
||||
"""
|
||||
A decorator to wrap a function that computes rewards.
|
||||
It will automatically handle the input and output of the function.
|
||||
"""Decorate a reward function so its outputs are tracked as spans.
|
||||
|
||||
The decorator integrates with AgentOps when it is available and falls back to
|
||||
the built-in telemetry otherwise. Both synchronous and asynchronous functions
|
||||
are supported transparently.
|
||||
|
||||
Deprecated:
|
||||
This decorator is deprecated. Use [`emit_reward`][agentlightning.emit_reward] instead.
|
||||
|
||||
Args:
|
||||
fn: Callable that produces a numeric reward.
|
||||
|
||||
Returns:
|
||||
Wrapped callable that preserves the original signature.
|
||||
"""
|
||||
|
||||
def wrap_result(result: Optional[float]) -> RewardSpanData:
|
||||
"""
|
||||
Wrap the result of the function in a dict.
|
||||
"""
|
||||
"""Normalize the reward value into the span payload format."""
|
||||
if result is None:
|
||||
return {"type": "reward", "value": None}
|
||||
if not isinstance(result, (float, int)): # type: ignore
|
||||
@@ -118,9 +129,20 @@ def reward(fn: FnType) -> FnType:
|
||||
return wrapper # type: ignore
|
||||
|
||||
|
||||
def emit_reward(reward: float) -> ReadableSpan:
|
||||
"""
|
||||
Record a new reward as a new span.
|
||||
def emit_reward(reward: float, auto_export: bool = True) -> ReadableSpan:
|
||||
"""Emit a reward value as an OpenTelemetry span.
|
||||
|
||||
Args:
|
||||
reward: Numeric reward to record. Integers and booleans are converted to
|
||||
floating point numbers for consistency.
|
||||
auto_export: Whether to export the span automatically.
|
||||
|
||||
Returns:
|
||||
Readable span capturing the recorded reward.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provided reward cannot be interpreted as a float or the
|
||||
resulting span is not a [`ReadableSpan`](https://opentelemetry.io/docs/concepts/signals/traces/) instance.
|
||||
"""
|
||||
logger.debug(f"Emitting reward: {reward}")
|
||||
if isinstance(reward, (int, bool)):
|
||||
@@ -128,7 +150,8 @@ def emit_reward(reward: float) -> ReadableSpan:
|
||||
if not isinstance(reward, float):
|
||||
raise ValueError(f"Reward must be a number, got: {type(reward)}")
|
||||
|
||||
tracer = get_tracer()
|
||||
# TODO: This should use the tracer from current context by tracer
|
||||
tracer = get_tracer(use_active_span_processor=auto_export)
|
||||
span = tracer.start_span(SpanNames.REWARD.value, attributes={"reward": reward})
|
||||
# Do nothing; it's just a number
|
||||
with span:
|
||||
@@ -139,8 +162,13 @@ def emit_reward(reward: float) -> ReadableSpan:
|
||||
|
||||
|
||||
def get_reward_value(span: SpanLike) -> Optional[float]:
|
||||
"""
|
||||
Get the reward value from a span.
|
||||
"""Extract the reward value from a span, if available.
|
||||
|
||||
Args:
|
||||
span: Span object produced by AgentOps or Agent Lightning emitters.
|
||||
|
||||
Returns:
|
||||
The reward encoded in the span or `None` when the span does not represent a reward.
|
||||
"""
|
||||
for key in [
|
||||
"agentops.task.output", # newer versions of agentops
|
||||
@@ -178,35 +206,31 @@ def get_reward_value(span: SpanLike) -> Optional[float]:
|
||||
|
||||
|
||||
def is_reward_span(span: SpanLike) -> bool:
|
||||
"""
|
||||
Check if a span is a reward span.
|
||||
"""
|
||||
"""Return ``True`` when the provided span encodes a reward value."""
|
||||
maybe_reward = get_reward_value(span)
|
||||
return maybe_reward is not None
|
||||
|
||||
|
||||
def find_reward_spans(spans: Sequence[SpanLike]) -> List[SpanLike]:
|
||||
"""
|
||||
Find all reward spans in the given list of spans.
|
||||
"""Return all reward spans in the provided sequence.
|
||||
|
||||
Args:
|
||||
spans: A list of spans (either ReadableSpan or Span).
|
||||
spans: Sequence containing [`ReadableSpan`](https://opentelemetry.io/docs/concepts/signals/traces/) objects or mocked span-like values.
|
||||
|
||||
Returns:
|
||||
A list of spans whose name matches the reward span name.
|
||||
List of spans that could be parsed as rewards.
|
||||
"""
|
||||
return [span for span in spans if is_reward_span(span)]
|
||||
|
||||
|
||||
def find_final_reward(spans: Sequence[SpanLike]) -> Optional[float]:
|
||||
"""
|
||||
Get the last reward value from a list of spans.
|
||||
"""Return the last reward value present in the provided spans.
|
||||
|
||||
Args:
|
||||
spans: A list of spans (either ReadableSpan or Span).
|
||||
spans: Sequence containing [`ReadableSpan`](https://opentelemetry.io/docs/concepts/signals/traces/) objects or mocked span-like values.
|
||||
|
||||
Returns:
|
||||
The reward value from the last reward span, or None if not found.
|
||||
Reward value from the latest reward span, or `None` when none are found.
|
||||
"""
|
||||
for span in reversed(spans):
|
||||
reward = get_reward_value(span)
|
||||
|
||||
@@ -1,22 +1,57 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Common utilities for the emitter module."""
|
||||
"""Utilities shared across emitter implementations."""
|
||||
|
||||
from typing import cast
|
||||
from warnings import filterwarnings
|
||||
|
||||
import opentelemetry.trace as trace_api
|
||||
from opentelemetry.sdk.trace import SpanLimits, SynchronousMultiSpanProcessor, Tracer
|
||||
from opentelemetry.sdk.trace import TracerProvider as TracerProviderImpl
|
||||
from opentelemetry.sdk.util.instrumentation import InstrumentationInfo, InstrumentationScope
|
||||
from opentelemetry.trace import get_tracer_provider
|
||||
|
||||
|
||||
def get_tracer() -> trace_api.Tracer:
|
||||
"""Return the tracer used for AgentLightning spans.
|
||||
def get_tracer(use_active_span_processor: bool = True) -> trace_api.Tracer:
|
||||
"""Resolve the OpenTelemetry tracer configured for Agent Lightning.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the tracer is not initialized.
|
||||
Args:
|
||||
use_active_span_processor: Whether to use the active span processor.
|
||||
|
||||
Returns:
|
||||
The AgentLightning tracer instance.
|
||||
OpenTelemetry tracer tagged with the `agentlightning` instrumentation name.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If OpenTelemetry was not initialized before calling this helper.
|
||||
"""
|
||||
if hasattr(trace_api, "_TRACER_PROVIDER") and trace_api._TRACER_PROVIDER is None: # type: ignore[attr-defined]
|
||||
raise RuntimeError("Tracer is not initialized. Cannot emit a meaningful span.")
|
||||
|
||||
tracer_provider = get_tracer_provider()
|
||||
return tracer_provider.get_tracer("agentlightning")
|
||||
tracer_provider = cast(TracerProviderImpl, get_tracer_provider())
|
||||
|
||||
if use_active_span_processor:
|
||||
return tracer_provider.get_tracer("agentlightning")
|
||||
|
||||
else:
|
||||
filterwarnings(
|
||||
"ignore",
|
||||
message=r"You should use InstrumentationScope. Deprecated since version 1.11.1.",
|
||||
category=DeprecationWarning,
|
||||
module="opentelemetry.sdk.trace",
|
||||
)
|
||||
|
||||
return Tracer(
|
||||
tracer_provider.sampler,
|
||||
tracer_provider.resource,
|
||||
# We use an empty span processor to avoid emitting spans to the tracer
|
||||
SynchronousMultiSpanProcessor(),
|
||||
tracer_provider.id_generator,
|
||||
InstrumentationInfo("agentlightning", "", ""), # type: ignore
|
||||
SpanLimits(),
|
||||
InstrumentationScope(
|
||||
"agentlightning",
|
||||
"",
|
||||
"",
|
||||
{},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Protocol
|
||||
|
||||
from agentlightning.store.base import LightningStore
|
||||
@@ -10,28 +13,94 @@ from .events import ExecutionEvent
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_TRUTHY_VALUES = {"1", "true", "yes", "on"}
|
||||
_FALSY_VALUES = {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def resolve_managed_store_flag(value: bool | None) -> bool:
|
||||
"""Determine whether execution helpers should wrap the provided store.
|
||||
|
||||
The helper first honours an explicit `value`. When `None` it falls back
|
||||
to the `AGL_MANAGED_STORE` environment variable, accepting a variety
|
||||
of truthy and falsy spellings. Missing environment configuration defaults to
|
||||
`True` so that higher-level strategies create the appropriate client or
|
||||
server wrappers automatically.
|
||||
|
||||
Args:
|
||||
value: Optional override supplied by the caller.
|
||||
|
||||
Returns:
|
||||
`True` when a managed store should be created around the provided
|
||||
instance, otherwise `False`.
|
||||
|
||||
Raises:
|
||||
ValueError: If `AGL_MANAGED_STORE` is set to an unsupported
|
||||
value.
|
||||
"""
|
||||
|
||||
if value is not None:
|
||||
return value
|
||||
|
||||
env_value = os.getenv("AGL_MANAGED_STORE")
|
||||
if env_value is None:
|
||||
return True
|
||||
|
||||
normalized = env_value.strip().lower()
|
||||
if normalized in _TRUTHY_VALUES:
|
||||
return True
|
||||
if normalized in _FALSY_VALUES:
|
||||
return False
|
||||
|
||||
raise ValueError("AGL_MANAGED_STORE must be one of 1, 0, true, false, yes, no, on, or off")
|
||||
|
||||
|
||||
class AlgorithmBundle(Protocol):
|
||||
"""Callable bundle produced by [`Trainer`][agentlightning.Trainer].
|
||||
|
||||
Execution strategies treat the returned coroutine as opaque, only providing
|
||||
the shared store instance and cooperative stop event. Bundles typically
|
||||
encapsulate algorithm setup plus adapter and LLM proxy, etc.
|
||||
"""
|
||||
|
||||
async def __call__(self, store: LightningStore, event: ExecutionEvent) -> None:
|
||||
"""Initalization and execution logic."""
|
||||
"""Execute algorithm logic using ``store`` until completion or stop."""
|
||||
|
||||
|
||||
class RunnerBundle(Protocol):
|
||||
"""Callable bundle wrapping runner setup and the worker loop, as opposed to the
|
||||
[`AlgorithmBundle`][agentlightning.AlgorithmBundle]."""
|
||||
|
||||
async def __call__(self, store: LightningStore, worker_id: int, event: ExecutionEvent) -> None:
|
||||
"""Initalization and execution logic."""
|
||||
"""Execute runner logic for ``worker_id`` using ``store`` and ``event``."""
|
||||
|
||||
|
||||
class ExecutionStrategy:
|
||||
"""When trainer has created the executable of algorithm and runner in two bundles,
|
||||
the execution strategy defines how to run them together, and how many parallel runners to run.
|
||||
"""Coordinate algorithm and runner bundles within a single process abstraction.
|
||||
|
||||
The store is the centric place for the two bundles to communicate.
|
||||
Strategies decide how many worker bundles to launch, whether to communicate
|
||||
through shared memory or an HTTP boundary, and how to react to shutdown
|
||||
signals. They intentionally avoid inspecting the bundle internals; instead,
|
||||
each bundle remains responsible for its own scheduling semantics.
|
||||
|
||||
The algorithm and runner's behavior (whether runner should perform one step or run forever,
|
||||
whether the algo would send out the tasks or not) are defined inside the bundle,
|
||||
and does not belong to the execution strategy.
|
||||
|
||||
The execute should support Ctrl+C to exit gracefully.
|
||||
!!! note
|
||||
Implementations must honor the [execute()][agentlightning.ExecutionStrategy.execute]
|
||||
contract by propagating `KeyboardInterrupt` and ensuring resources are
|
||||
released when an error occurs on either side of the algorithm/runner
|
||||
pair.
|
||||
"""
|
||||
|
||||
def execute(self, algorithm: AlgorithmBundle, runner: RunnerBundle, store: LightningStore) -> None:
|
||||
"""Run the provided bundles using the configured orchestration model.
|
||||
|
||||
Args:
|
||||
algorithm: Callable bundle responsible for algorithm execution.
|
||||
runner: Callable bundle for runner workers.
|
||||
store: Concrete [`LightningStore`][agentlightning.LightningStore]
|
||||
shared across bundles.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must provide the orchestration
|
||||
implementation.
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -12,52 +12,51 @@ from typing import Callable, Iterable, Literal, cast
|
||||
from agentlightning.store.base import LightningStore
|
||||
from agentlightning.store.client_server import LightningStoreClient, LightningStoreServer
|
||||
|
||||
from .base import AlgorithmBundle, ExecutionStrategy, RunnerBundle
|
||||
from .base import AlgorithmBundle, ExecutionStrategy, RunnerBundle, resolve_managed_store_flag
|
||||
from .events import ExecutionEvent, MultiprocessingEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClientServerExecutionStrategy(ExecutionStrategy):
|
||||
"""Run algorithm (server) and runners (clients) as separate processes over HTTP.
|
||||
"""Run algorithm and runner bundles as separate processes over HTTP.
|
||||
|
||||
**Execution Roles:**
|
||||
Execution Roles:
|
||||
|
||||
- "algorithm": Start the HTTP server (`LightningStoreServer`) in-process and run the
|
||||
algorithm bundle against it.
|
||||
- "runner": Connect to an already running server via `LightningStoreClient` and
|
||||
execute runner bundles (optionally in multiple processes).
|
||||
- "both": Spawn the runner processes first, then launch the algorithm/server
|
||||
bundle on the main process. This mode orchestrates the full loop locally.
|
||||
- `"algorithm"`: Start [`LightningStoreServer`][agentlightning.LightningStoreServer]
|
||||
in-process and execute the algorithm bundle against it.
|
||||
- `"runner"`: Connect to an existing server with
|
||||
[`LightningStoreClient`][agentlightning.LightningStoreClient] and run the
|
||||
runner bundle locally (spawning multiple processes when requested).
|
||||
- `"both"`: Spawn runner processes first, then execute the algorithm and
|
||||
server on the same machine. This mode orchestrates the full loop locally.
|
||||
|
||||
When role == "both", you may choose which side runs on the main process via
|
||||
`main_process` (debug helper). Running the runner bundle on the main process
|
||||
is only supported with `n_runners == 1`.
|
||||
When `role == "both"` you may choose which side runs on the main process
|
||||
via `main_process`. The runner-on-main option is limited to
|
||||
`n_runners == 1` because each additional runner requires its own event
|
||||
loop and process.
|
||||
|
||||
Important: When `main_process == "runner"`, the algorithm runs in a subprocess
|
||||
with the LightningStore server. This means any state modifications made during
|
||||
execution remain in that subprocess and are NOT reflected in the original store
|
||||
object passed to `execute()`. The main process runner accesses the store only
|
||||
through the HTTP client interface.
|
||||
!!! warning
|
||||
When `main_process == "runner"` the algorithm and HTTP server execute
|
||||
in a child process. Store mutations remain isolated inside that process,
|
||||
so the original store instance passed to
|
||||
[execute()][agentlightning.ExecutionStrategy.execute] is not updated.
|
||||
|
||||
**Abort / Stop Model (four-step escalation):**
|
||||
Abort Model (four-step escalation):
|
||||
|
||||
1. Cooperative stop:
|
||||
A shared :class:`~agentlightning.execution.events.MultiprocessingEvent`
|
||||
(`stop_evt`) is passed to *all* bundles. Bundles should check it to exit.
|
||||
Any crash (algorithm or runner) sets `stop_evt` so the other side can
|
||||
stop cooperatively. Ctrl+C on the main process also flips the event.
|
||||
2. KeyboardInterrupt synth:
|
||||
Remaining subprocesses receive `SIGINT` to trigger `KeyboardInterrupt`
|
||||
handlers.
|
||||
3. Termination:
|
||||
Stubborn subprocesses get `terminate()` (SIGTERM on POSIX).
|
||||
4. Kill:
|
||||
As a last resort we call `kill()` (SIGKILL on POSIX).
|
||||
1. Cooperative stop. Every bundle receives a shared
|
||||
[`MultiprocessingEvent`][agentlightning.MultiprocessingEvent] (`stop_evt`).
|
||||
Any failure flips the event so peers can exit cleanly. Ctrl+C on the main
|
||||
process also sets the flag.
|
||||
2. KeyboardInterrupt synthesis. Remaining subprocesses receive ``SIGINT`` to
|
||||
trigger `KeyboardInterrupt` handlers.
|
||||
3. Termination. Stubborn processes are asked to ``terminate()``
|
||||
(`SIGTERM` on POSIX).
|
||||
4. Kill. As a last resort `kill()` is invoked (`SIGKILL` on POSIX).
|
||||
|
||||
Notes:
|
||||
This mirrors the semantics implemented in :mod:`shared_memory`, but adapted
|
||||
to multiple processes and the HTTP client/server boundary.
|
||||
This mirrors the semantics implemented in
|
||||
[`SharedMemoryExecutionStrategy`][agentlightning.SharedMemoryExecutionStrategy]
|
||||
but adapts them to multiple processes and the HTTP client/server boundary.
|
||||
"""
|
||||
|
||||
alias: str = "cs"
|
||||
@@ -68,20 +67,22 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
|
||||
server_host: str | None = None,
|
||||
server_port: int | None = None,
|
||||
n_runners: int = 1,
|
||||
graceful_timeout: float = 5.0,
|
||||
terminate_timeout: float = 5.0,
|
||||
graceful_timeout: float = 10.0,
|
||||
terminate_timeout: float = 10.0,
|
||||
main_process: Literal["algorithm", "runner"] = "algorithm",
|
||||
managed_store: bool | None = None,
|
||||
allowed_exit_codes: Iterable[int] = (0, -15),
|
||||
) -> None:
|
||||
"""Configure the strategy.
|
||||
|
||||
Args:
|
||||
role: Which side(s) to run in this process. When omitted, the
|
||||
:envvar:`AGL_CURRENT_ROLE` environment variable is used.
|
||||
`AGL_CURRENT_ROLE` environment variable is used.
|
||||
server_host: Interface the HTTP server binds to when running the
|
||||
algorithm bundle locally. Defaults to :envvar:`AGL_SERVER_HOST`
|
||||
or ``"localhost"`` if unset.
|
||||
algorithm bundle locally. Defaults to `AGL_SERVER_HOST`
|
||||
or `"localhost"` if unset.
|
||||
server_port: Port for the HTTP server in "algorithm"/"both" modes.
|
||||
Defaults to :envvar:`AGL_SERVER_PORT` or ``4747`` if unset.
|
||||
Defaults to `AGL_SERVER_PORT` or `4747` if unset.
|
||||
n_runners: Number of runner processes to spawn in "runner"/"both".
|
||||
graceful_timeout: How long to wait (seconds) after setting the stop
|
||||
event before escalating to signals.
|
||||
@@ -90,14 +91,23 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
|
||||
main_process: Which bundle runs on the main process when
|
||||
`role == "both"`. `"runner"` requires `n_runners == 1` and is
|
||||
primarily intended for debugging.
|
||||
managed_store: When `True` (default) the strategy constructs
|
||||
LightningStore client/server wrappers automatically. When
|
||||
`False` the provided `store` is passed directly to the
|
||||
bundles, allowing callers to manage store wrappers manually.
|
||||
allowed_exit_codes: Allowed exit codes for subprocesses.
|
||||
By default, runner can exit gracefully with code 0 or terminated
|
||||
by SIGTERM (-15).
|
||||
"""
|
||||
if role is None:
|
||||
role_env = os.getenv("AGL_CURRENT_ROLE")
|
||||
if role_env is None:
|
||||
raise ValueError("role must be provided via argument or AGL_CURRENT_ROLE env var")
|
||||
if role_env not in ("algorithm", "runner", "both"):
|
||||
# Use both if not specified via env var or argument
|
||||
role = "both"
|
||||
elif role_env not in ("algorithm", "runner", "both"):
|
||||
raise ValueError("role must be one of 'algorithm', 'runner', or 'both'")
|
||||
role = role_env
|
||||
else:
|
||||
role = role_env
|
||||
|
||||
if server_host is None:
|
||||
server_host = os.getenv("AGL_SERVER_HOST", "localhost")
|
||||
@@ -126,19 +136,27 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
|
||||
if n_runners != 1:
|
||||
raise ValueError("main_process='runner' requires n_runners to be 1")
|
||||
self.main_process = main_process
|
||||
self.managed_store = resolve_managed_store_flag(managed_store)
|
||||
self.allowed_exit_codes = tuple(allowed_exit_codes)
|
||||
|
||||
async def _execute_algorithm(
|
||||
self, algorithm: AlgorithmBundle, store: LightningStore, stop_evt: ExecutionEvent
|
||||
) -> None:
|
||||
logger.info("Starting LightningStore server on %s:%s", self.server_host, self.server_port)
|
||||
server_store = LightningStoreServer(store, host=self.server_host, port=self.server_port)
|
||||
server_started = False
|
||||
wrapper_store: LightningStore | None = None
|
||||
if self.managed_store:
|
||||
logger.info("Starting LightningStore server on %s:%s", self.server_host, self.server_port)
|
||||
wrapper_store = LightningStoreServer(store, host=self.server_host, port=self.server_port)
|
||||
server_started = False
|
||||
else:
|
||||
wrapper_store = store
|
||||
server_started = False
|
||||
|
||||
try:
|
||||
await server_store.start()
|
||||
server_started = True
|
||||
logger.debug("Algorithm bundle starting against endpoint %s", server_store.endpoint)
|
||||
await algorithm(server_store, stop_evt)
|
||||
if self.managed_store and isinstance(wrapper_store, LightningStoreServer):
|
||||
await wrapper_store.start()
|
||||
server_started = True
|
||||
logger.debug("Algorithm bundle starting against endpoint %s", wrapper_store.endpoint)
|
||||
await algorithm(wrapper_store, stop_evt)
|
||||
logger.debug("Algorithm bundle completed successfully")
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Algorithm received KeyboardInterrupt; signaling stop event")
|
||||
@@ -149,18 +167,31 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
|
||||
stop_evt.set()
|
||||
raise
|
||||
finally:
|
||||
if server_started:
|
||||
if self.managed_store and isinstance(wrapper_store, LightningStoreServer) and server_started:
|
||||
try:
|
||||
await server_store.stop()
|
||||
await wrapper_store.stop()
|
||||
except Exception:
|
||||
logger.exception("Error stopping LightningStore server")
|
||||
else:
|
||||
logger.debug("LightningStore server shutdown completed")
|
||||
|
||||
async def _execute_runner(self, runner: RunnerBundle, worker_id: int, stop_evt: ExecutionEvent) -> None:
|
||||
client_store = LightningStoreClient(f"http://{self.server_host}:{self.server_port}")
|
||||
async def _execute_runner(
|
||||
self,
|
||||
runner: RunnerBundle,
|
||||
worker_id: int,
|
||||
store: LightningStore,
|
||||
stop_evt: ExecutionEvent,
|
||||
) -> None:
|
||||
if self.managed_store:
|
||||
# If managed, we actually do not use the provided store
|
||||
client_store = LightningStoreClient(f"http://{self.server_host}:{self.server_port}")
|
||||
else:
|
||||
client_store = store
|
||||
try:
|
||||
logger.debug("Runner %s connecting to server at %s:%s", worker_id, self.server_host, self.server_port)
|
||||
if self.managed_store:
|
||||
logger.debug("Runner %s connecting to server at %s:%s", worker_id, self.server_host, self.server_port)
|
||||
else:
|
||||
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 KeyboardInterrupt:
|
||||
@@ -172,16 +203,18 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
|
||||
stop_evt.set()
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
await client_store.close()
|
||||
except Exception:
|
||||
logger.exception("Error closing LightningStore client for runner %s", worker_id)
|
||||
else:
|
||||
logger.debug("Runner %s closed LightningStore client", worker_id)
|
||||
if self.managed_store and isinstance(client_store, LightningStoreClient):
|
||||
try:
|
||||
await client_store.close()
|
||||
except Exception:
|
||||
logger.exception("Error closing LightningStore client for runner %s", worker_id)
|
||||
else:
|
||||
logger.debug("Runner %s closed LightningStore client", worker_id)
|
||||
|
||||
def _spawn_runners(
|
||||
self,
|
||||
runner: RunnerBundle,
|
||||
store: LightningStore,
|
||||
stop_evt: ExecutionEvent,
|
||||
*,
|
||||
ctx: BaseContext,
|
||||
@@ -189,15 +222,15 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
|
||||
"""Used when `role == "runner"` or `role == "both"` and `n_runners > 1`."""
|
||||
processes: list[multiprocessing.Process] = []
|
||||
|
||||
def _runner_sync(runner: RunnerBundle, worker_id: int, stop_evt: ExecutionEvent) -> None:
|
||||
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, stop_evt))
|
||||
asyncio.run(self._execute_runner(runner, worker_id, store, stop_evt))
|
||||
|
||||
for i in range(self.n_runners):
|
||||
process = cast(
|
||||
multiprocessing.Process,
|
||||
ctx.Process(target=_runner_sync, args=(runner, i, stop_evt), name=f"runner-{i}"), # type: ignore
|
||||
ctx.Process(target=_runner_sync, args=(runner, i, store, stop_evt), name=f"runner-{i}"), # type: ignore
|
||||
)
|
||||
process.start()
|
||||
logger.debug("Spawned runner process %s (pid=%s)", process.name, process.pid)
|
||||
@@ -310,10 +343,10 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
|
||||
|
||||
def _check_process_exitcodes(self, processes: Iterable[multiprocessing.Process]) -> None:
|
||||
"""Raise an error if any managed process exited with a non-zero status."""
|
||||
failed = [p for p in processes if p.exitcode not in (0, None)]
|
||||
failed = [p for p in processes if p.exitcode not in self.allowed_exit_codes + (None,)]
|
||||
if failed:
|
||||
formatted = ", ".join(f"{p.name or p.pid} (exitcode={p.exitcode})" for p in failed)
|
||||
raise RuntimeError(f"Subprocesses failed: {formatted}")
|
||||
raise RuntimeError(f"Subprocesses failed with unexpected exit codes: {formatted}")
|
||||
|
||||
def execute(self, algorithm: AlgorithmBundle, runner: RunnerBundle, store: LightningStore) -> None:
|
||||
logger.info(
|
||||
@@ -341,10 +374,10 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
|
||||
elif self.role == "runner":
|
||||
if self.n_runners == 1:
|
||||
logger.info("Running runner solely...")
|
||||
asyncio.run(self._execute_runner(runner, 0, stop_evt))
|
||||
asyncio.run(self._execute_runner(runner, 0, store, stop_evt))
|
||||
else:
|
||||
logger.info("Spawning runner processes...")
|
||||
processes = self._spawn_runners(runner, stop_evt, ctx=ctx)
|
||||
processes = self._spawn_runners(runner, store, stop_evt, ctx=ctx)
|
||||
# Wait for the processes to finish naturally.
|
||||
for process in processes:
|
||||
process.join()
|
||||
@@ -352,7 +385,7 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
|
||||
elif self.role == "both":
|
||||
if self.main_process == "algorithm":
|
||||
logger.info("Spawning runner processes...")
|
||||
processes = self._spawn_runners(runner, stop_evt, ctx=ctx)
|
||||
processes = self._spawn_runners(runner, store, stop_evt, ctx=ctx)
|
||||
try:
|
||||
logger.info("Running algorithm...")
|
||||
asyncio.run(self._execute_algorithm(algorithm, store, stop_evt))
|
||||
@@ -373,7 +406,7 @@ class ClientServerExecutionStrategy(ExecutionStrategy):
|
||||
# the background process spawned above (the provided
|
||||
# store must therefore be picklable when using spawn).
|
||||
logger.info("Running runner...")
|
||||
asyncio.run(self._execute_runner(runner, 0, stop_evt))
|
||||
asyncio.run(self._execute_runner(runner, 0, store, stop_evt))
|
||||
|
||||
# Wait for the algorithm process to finish.
|
||||
algorithm_process.join()
|
||||
|
||||
@@ -7,15 +7,18 @@ from typing import Optional, Protocol
|
||||
|
||||
|
||||
class ExecutionEvent(Protocol):
|
||||
"""
|
||||
A minimal protocol similar to threading.Event.
|
||||
"""Protocol capturing the cooperative stop contract shared by strategies.
|
||||
|
||||
Implementations mirror the API of ``threading.Event`` and
|
||||
``multiprocessing.Event`` so the rest of the execution layer can remain
|
||||
agnostic to the underlying concurrency primitive.
|
||||
|
||||
Methods:
|
||||
set(): Signal event like a cancellation (idempotent).
|
||||
clear(): Reset to the non-set state.
|
||||
is_set() -> bool: True if event has been signaled.
|
||||
wait(timeout: Optional[float] = None) -> bool:
|
||||
Block until event is set or timeout. Returns True if event has signaled.
|
||||
|
||||
set: Signal cancellation. The call must be idempotent.
|
||||
clear: Reset the event to the unsignaled state.
|
||||
is_set: Return ``True`` when cancellation has been requested.
|
||||
wait: Block until the event is signaled or an optional timeout elapses.
|
||||
"""
|
||||
|
||||
def set(self) -> None: ...
|
||||
@@ -25,11 +28,7 @@ class ExecutionEvent(Protocol):
|
||||
|
||||
|
||||
class ThreadingEvent:
|
||||
"""
|
||||
An Event implementation using threading.Event.
|
||||
|
||||
Provides a thread-safe event object for signaling between threads.
|
||||
"""
|
||||
"""Thread-safe implementation of [`ExecutionEvent`][agentlightning.ExecutionEvent]."""
|
||||
|
||||
__slots__ = ("_evt",)
|
||||
|
||||
@@ -50,12 +49,7 @@ class ThreadingEvent:
|
||||
|
||||
|
||||
class MultiprocessingEvent:
|
||||
"""
|
||||
An Event implementation using multiprocessing.Event.
|
||||
|
||||
Provides a process-safe event object for signaling between processes.
|
||||
Optionally accepts a multiprocessing context for custom process start methods.
|
||||
"""
|
||||
"""Process-safe implementation of [`ExecutionEvent`][agentlightning.ExecutionEvent]."""
|
||||
|
||||
__slots__ = ("_evt",)
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@ from .base import ExecutionStrategy
|
||||
|
||||
|
||||
class InterProcessExecutionStrategy(ExecutionStrategy):
|
||||
"""Placeholder strategy for future inter-process primitives.
|
||||
|
||||
The class exists to reserve the `ipc` alias and make the planned
|
||||
implementation discoverable. Attempting to use it today will raise
|
||||
`NotImplementedError` once the execution contract is finalized.
|
||||
"""
|
||||
|
||||
alias: str = "ipc"
|
||||
|
||||
|
||||
@@ -10,28 +10,33 @@ from typing import Any, Awaitable, Callable, List, Literal, Optional, Tuple
|
||||
from agentlightning.store.base import LightningStore
|
||||
from agentlightning.store.threading import LightningStoreThreaded
|
||||
|
||||
from .base import AlgorithmBundle, ExecutionStrategy, RunnerBundle
|
||||
from .base import AlgorithmBundle, ExecutionStrategy, RunnerBundle, resolve_managed_store_flag
|
||||
from .events import ExecutionEvent, ThreadingEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SharedMemoryExecutionStrategy(ExecutionStrategy):
|
||||
"""Run algorithm and runners in a single process with threads sharing memory.
|
||||
"""Execute bundles in a single process with cooperative worker threads.
|
||||
|
||||
Termination & abort model:
|
||||
Stop Model:
|
||||
|
||||
- One shared ThreadingEvent (`stop_evt`) is passed to *all* bundles.
|
||||
- The main thread (only) receives KeyboardInterrupt on Ctrl+C; we set `stop_evt` there.
|
||||
- If any bundle raises, we set `stop_evt` from that thread to stop the rest.
|
||||
- After the main-thread bundle finishes normally:
|
||||
- If main_thread is "algorithm", we also set `stop_evt` to stop the runners.
|
||||
- If main_thread is "runner", we do not set `stop_evt` to stop the algorithm.
|
||||
We instead wait for the algorithm to finish naturally.
|
||||
- Background threads are daemons; we join briefly and log any stragglers.
|
||||
- All bundles share one [`ThreadingEvent`][agentlightning.ThreadingEvent]
|
||||
named `stop_evt`.
|
||||
- Only the main thread receives `KeyboardInterrupt`. When Ctrl+C occurs we
|
||||
set `stop_evt`.
|
||||
- Any exception raised inside a bundle sets `stop_evt` so other threads can
|
||||
unwind cooperatively.
|
||||
- Once the bundle running on the main thread exits successfully the
|
||||
treatment depends on `main_thread`:
|
||||
- `"algorithm"`: the runners are asked to stop by setting `stop_evt`.
|
||||
- `"runner"`: the algorithm keeps running until it exits naturally.
|
||||
- Background threads are marked as daemons. We join them briefly and log any
|
||||
stragglers before shutting down.
|
||||
|
||||
Notes: Signals other than SIGINT (e.g., SIGTERM) are not intercepted; we respect
|
||||
Python's default behavior for them.
|
||||
!!! note
|
||||
Signals other than `SIGINT` (such as `SIGTERM`) are not intercepted;
|
||||
Python's default behavior for those signals is preserved.
|
||||
"""
|
||||
|
||||
alias: str = "shm"
|
||||
@@ -43,32 +48,39 @@ class SharedMemoryExecutionStrategy(ExecutionStrategy):
|
||||
join_timeout: float = 15.0,
|
||||
graceful_delay: float = 5.0,
|
||||
poll_interval: float = 0.05,
|
||||
managed_store: bool | None = None,
|
||||
) -> None:
|
||||
if main_thread not in ("algorithm", "runner"):
|
||||
raise ValueError("main_thread must be 'algorithm' or 'runner'")
|
||||
if main_thread == "runner" and n_runners != 1:
|
||||
raise ValueError("When main_thread is 'runner', n_runners must be 1")
|
||||
raise ValueError(
|
||||
"When main_thread is 'runner', n_runners must be 1. "
|
||||
"Either use 'algorithm' on the main thread or set n_runners to 1."
|
||||
)
|
||||
self.n_runners = n_runners
|
||||
self.main_thread = main_thread
|
||||
self.join_timeout = join_timeout
|
||||
self.graceful_delay = graceful_delay
|
||||
self.poll_interval = poll_interval
|
||||
self.managed_store = resolve_managed_store_flag(managed_store)
|
||||
|
||||
async def _run_until_completed_or_canceled(self, coro: Awaitable[Any], stop_evt: ExecutionEvent) -> Any:
|
||||
"""Run `coro` until it finishes or a cooperative stop is requested.
|
||||
|
||||
Control flow:
|
||||
1) Start the bundle coroutine as `task`.
|
||||
2) Start a watcher task that waits for `stop_evt` *without blocking* the loop
|
||||
by periodically polling the threading event.
|
||||
3) When the stop event flips:
|
||||
a) Give the bundle *graceful_delay* seconds to finish on its own,
|
||||
because well-behaved bundles will check the event and return.
|
||||
b) If still running after the grace period, cancel the bundle task.
|
||||
4) Ensure both tasks are awaited; swallow `CancelledError` where appropriate.
|
||||
|
||||
1. Start the bundle coroutine as `task`.
|
||||
2. Launch a watcher that polls `stop_evt` without blocking the loop.
|
||||
3. When the stop event flips:
|
||||
a. Give the bundle `graceful_delay` seconds to finish on its own,
|
||||
because well-behaved bundles will check the event and return.
|
||||
b. Cancel the bundle task if it is still running after the grace
|
||||
period.
|
||||
4. Await both tasks and swallow `CancelledError` where appropriate.
|
||||
|
||||
This is a *backup* mechanism for bundles that might not poll the event
|
||||
frequently; cooperative shutdown (checking `stop_evt` yourself) is still preferred.
|
||||
frequently; cooperative shutdown (checking `stop_evt` inside the
|
||||
bundle) remains the preferred approach.
|
||||
"""
|
||||
task: asyncio.Task[Any] = asyncio.create_task(coro) # type: ignore
|
||||
task_exception: Optional[BaseException] = None
|
||||
@@ -191,7 +203,10 @@ class SharedMemoryExecutionStrategy(ExecutionStrategy):
|
||||
|
||||
# Create stop event and thread-safe store.
|
||||
stop_evt = ThreadingEvent()
|
||||
thread_safe_store = LightningStoreThreaded(store)
|
||||
if self.managed_store:
|
||||
thread_safe_store = LightningStoreThreaded(store)
|
||||
else:
|
||||
thread_safe_store = store
|
||||
|
||||
thread_exceptions: SimpleQueue[BaseException] = SimpleQueue()
|
||||
raised_from_thread: Optional[BaseException] = None
|
||||
|
||||
@@ -2,28 +2,78 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import signal
|
||||
import socket
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, no_type_check
|
||||
|
||||
import flask
|
||||
import setproctitle
|
||||
import requests
|
||||
from agentops.client.api import V3Client, V4Client
|
||||
from agentops.client.api.types import AuthTokenResponse
|
||||
from agentops.sdk.exporters import AuthenticatedOTLPExporter
|
||||
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.sdk.metrics.export import MetricExportResult
|
||||
|
||||
from agentlightning.utils.otlp import LightningStoreOTLPExporter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"instrument_agentops",
|
||||
"uninstrument_agentops",
|
||||
"agentops_local_server",
|
||||
"AgentOpsServerManager",
|
||||
]
|
||||
|
||||
# Module-level storage for originals
|
||||
_original_handle_chat_attributes: Callable[..., Any] | None = None
|
||||
_original_handle_response: Callable[..., Any] | None = None
|
||||
_agentops_service_enabled = False
|
||||
|
||||
|
||||
def enable_agentops_service(enabled: bool = True) -> None:
|
||||
"""
|
||||
Enable or disable communication with the AgentOps service.
|
||||
|
||||
By default, AgentOps exporters and clients will run in local mode
|
||||
and will NOT attempt to communicate with the remote AgentOps service.
|
||||
|
||||
Args:
|
||||
enabled: If True, enable all AgentOps exporters and clients.
|
||||
All exporters and clients will operate in normal mode and send data
|
||||
to the [AgentOps service](https://www.agentops.ai).
|
||||
"""
|
||||
global _agentops_service_enabled
|
||||
_agentops_service_enabled = enabled
|
||||
logger.info(f"AgentOps service enabled is set to {enabled}.")
|
||||
|
||||
|
||||
def _patch_exporters():
|
||||
import agentops.client.api
|
||||
import agentops.sdk.core
|
||||
|
||||
agentops.sdk.core.AuthenticatedOTLPExporter = BypassableAuthenticatedOTLPExporter # type: ignore
|
||||
agentops.sdk.core.OTLPMetricExporter = BypassableOTLPMetricExporter
|
||||
if hasattr(agentops.sdk.core, "OTLPSpanExporter"):
|
||||
agentops.sdk.core.OTLPSpanExporter = BypassableOTLPSpanExporter # type: ignore
|
||||
agentops.client.api.V3Client = BypassableV3Client
|
||||
agentops.client.api.V4Client = BypassableV4Client
|
||||
|
||||
|
||||
def _unpatch_exporters():
|
||||
import agentops.client.api
|
||||
import agentops.sdk.core
|
||||
|
||||
agentops.sdk.core.AuthenticatedOTLPExporter = AuthenticatedOTLPExporter # type: ignore
|
||||
agentops.sdk.core.OTLPMetricExporter = OTLPMetricExporter
|
||||
if hasattr(agentops.sdk.core, "OTLPSpanExporter"):
|
||||
agentops.sdk.core.OTLPSpanExporter = OTLPSpanExporter # type: ignore
|
||||
agentops.client.api.V3Client = V3Client
|
||||
agentops.client.api.V4Client = V4Client
|
||||
|
||||
|
||||
def _unwrap_legacy_response(response: Any) -> Any:
|
||||
if hasattr(response, "parse") and callable(response.parse):
|
||||
return response.parse()
|
||||
return response
|
||||
|
||||
|
||||
def _patch_new_agentops():
|
||||
@@ -39,41 +89,58 @@ def _patch_new_agentops():
|
||||
|
||||
_original_handle_chat_attributes = handle_chat_attributes # type: ignore
|
||||
|
||||
@no_type_check
|
||||
def _handle_chat_attributes_with_tokens(args=None, kwargs=None, return_value=None, **kws): # type: ignore
|
||||
attributes = _original_handle_chat_attributes(args=args, kwargs=kwargs, return_value=return_value, **kws) # type: ignore
|
||||
if return_value is not None and hasattr(return_value, "prompt_token_ids"): # type: ignore
|
||||
attributes["prompt_token_ids"] = list(return_value.prompt_token_ids) # type: ignore
|
||||
if return_value is not None and hasattr(return_value, "response_token_ids"): # type: ignore
|
||||
attributes["response_token_ids"] = list(return_value.response_token_ids[0]) # type: ignore
|
||||
attributes = _original_handle_chat_attributes(args=args, kwargs=kwargs, return_value=return_value, **kws)
|
||||
|
||||
# In some cases, response is a openai._legacy_response.LegacyAPIResponse (e.g., LiteLLM, or LangChain),
|
||||
# This is created by client.with_raw_response.create()
|
||||
return_value = _unwrap_legacy_response(return_value)
|
||||
|
||||
if (
|
||||
return_value is not None
|
||||
and hasattr(return_value, "prompt_token_ids")
|
||||
and return_value.prompt_token_ids is not None
|
||||
):
|
||||
attributes["prompt_token_ids"] = list(return_value.prompt_token_ids)
|
||||
if (
|
||||
return_value is not None
|
||||
and hasattr(return_value, "response_token_ids")
|
||||
and return_value.response_token_ids is not None
|
||||
):
|
||||
attributes["response_token_ids"] = list(return_value.response_token_ids[0])
|
||||
|
||||
# For LiteLLM Proxy (v0.2) with vLLM return_token_ids, response_token_ids now lives in choices
|
||||
if (
|
||||
not attributes.get("response_token_ids")
|
||||
and return_value is not None
|
||||
and hasattr(return_value, "choices") # type: ignore
|
||||
and return_value.choices # type: ignore
|
||||
and isinstance(return_value.choices, list) # type: ignore
|
||||
):
|
||||
first_choice = return_value.choices[0] # type: ignore
|
||||
if hasattr(first_choice, "token_ids"): # type: ignore
|
||||
attributes["response_token_ids"] = list(first_choice.token_ids) # type: ignore
|
||||
# newer versions of OpenAI client SDK
|
||||
elif hasattr(first_choice, "provider_specific_fields") and "token_ids" in first_choice.provider_specific_fields: # type: ignore
|
||||
attributes["response_token_ids"] = list(first_choice.provider_specific_fields["token_ids"]) # type: ignore
|
||||
|
||||
# For LiteLLM, response is a openai._legacy_response.LegacyAPIResponse
|
||||
if (
|
||||
return_value is not None
|
||||
and hasattr(return_value, "http_response") # type: ignore
|
||||
and return_value.http_response is not None # type: ignore
|
||||
and hasattr(return_value.http_response, "json") # type: ignore
|
||||
and hasattr(return_value, "choices")
|
||||
and return_value.choices
|
||||
and isinstance(return_value.choices, list)
|
||||
and len(return_value.choices) > 0
|
||||
):
|
||||
json_data = return_value.http_response.json() # type: ignore
|
||||
if isinstance(json_data, dict):
|
||||
if "prompt_token_ids" in json_data:
|
||||
attributes["prompt_token_ids"] = list(json_data["prompt_token_ids"]) # type: ignore
|
||||
if "response_token_ids" in json_data:
|
||||
attributes["response_token_ids"] = list(json_data["response_token_ids"][0]) # type: ignore
|
||||
first_choice = return_value.choices[0]
|
||||
# Token IDs from "choices[0].token_ids"
|
||||
if "response_token_ids" not in attributes:
|
||||
if hasattr(first_choice, "token_ids") and first_choice.token_ids is not None:
|
||||
attributes["response_token_ids"] = list(first_choice.token_ids)
|
||||
# newer versions of OpenAI client SDK
|
||||
elif (
|
||||
hasattr(first_choice, "provider_specific_fields")
|
||||
and first_choice.provider_specific_fields.get("token_ids") is not None
|
||||
):
|
||||
attributes["response_token_ids"] = list(first_choice.provider_specific_fields["token_ids"])
|
||||
|
||||
# log probability
|
||||
# This is temporary. We need a unified convention for classifying and naming logprobs.
|
||||
if hasattr(first_choice, "logprobs") and first_choice.logprobs is not None:
|
||||
if hasattr(first_choice.logprobs, "content") and first_choice.logprobs.content is not None:
|
||||
attributes["logprobs.content"] = json.dumps(
|
||||
[logprob.model_dump() for logprob in first_choice.logprobs.content]
|
||||
)
|
||||
if hasattr(first_choice.logprobs, "refusal") and first_choice.logprobs.refusal is not None:
|
||||
attributes["logprobs.refusal"] = json.dumps(
|
||||
[logprob.model_dump() for logprob in first_choice.logprobs.refusal]
|
||||
)
|
||||
|
||||
return attributes
|
||||
|
||||
@@ -145,6 +212,8 @@ def instrument_agentops():
|
||||
Instrument agentops to capture token IDs.
|
||||
Automatically detects and uses the appropriate patching method based on the installed agentops version.
|
||||
"""
|
||||
_patch_exporters()
|
||||
|
||||
# Try newest version first (tested for 0.4.16)
|
||||
try:
|
||||
return _patch_new_agentops()
|
||||
@@ -164,6 +233,8 @@ def instrument_agentops():
|
||||
|
||||
def uninstrument_agentops():
|
||||
"""Uninstrument agentops to stop capturing token IDs."""
|
||||
_unpatch_exporters()
|
||||
|
||||
try:
|
||||
_unpatch_new_agentops()
|
||||
except Exception:
|
||||
@@ -174,102 +245,70 @@ def uninstrument_agentops():
|
||||
pass
|
||||
|
||||
|
||||
def agentops_local_server():
|
||||
class BypassableAuthenticatedOTLPExporter(LightningStoreOTLPExporter, AuthenticatedOTLPExporter):
|
||||
"""
|
||||
Returns a Flask app that can be used to test agentops integration.
|
||||
This server provides endpoints for token fetching and a catch-all endpoint.
|
||||
AuthenticatedOTLPExporter with switchable service control.
|
||||
|
||||
When `_agentops_service_enabled` is False, skip export and return success.
|
||||
"""
|
||||
app = flask.Flask(__name__)
|
||||
|
||||
@app.route("/v3/auth/token", methods=["POST"])
|
||||
def fetch_token(): # type: ignore
|
||||
return {"token": "dummy", "project_id": "dummy"}
|
||||
|
||||
@app.route("/", defaults={"path": ""}, methods=["GET", "POST"])
|
||||
@app.route("/<path:path>", methods=["GET", "POST"])
|
||||
def catch_all(path: str): # type: ignore
|
||||
return {"path": path}
|
||||
|
||||
return app
|
||||
def should_bypass(self) -> bool:
|
||||
return not _agentops_service_enabled
|
||||
|
||||
|
||||
def _run_server(**kwargs: Any): # type: ignore
|
||||
class BypassableOTLPMetricExporter(OTLPMetricExporter):
|
||||
"""
|
||||
Internal function to run the Flask server.
|
||||
This is used to avoid issues with multiprocessing and Flask's reloader.
|
||||
OTLPMetricExporter with switchable service control.
|
||||
When `_agentops_service_enabled` is False, skip export and return success.
|
||||
"""
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN) # Ignore SIGINT in worker processes
|
||||
setproctitle.setproctitle(multiprocessing.current_process().name)
|
||||
app = agentops_local_server()
|
||||
app.run(**kwargs)
|
||||
|
||||
|
||||
class AgentOpsServerManager:
|
||||
"""Manages a AgentOps local server to bypass the online service of AgentOps."""
|
||||
|
||||
def __init__(self, daemon: bool = True, port: int | None = None):
|
||||
self.server_process: multiprocessing.Process | None = None
|
||||
self.server_port = port
|
||||
self.daemon = daemon
|
||||
logger.info("AgentOpsServerManager initialized.")
|
||||
|
||||
def _find_available_port(self) -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
def start(self):
|
||||
if self.server_process and self.server_process.is_alive():
|
||||
logger.warning("AgentOps server process appears to be already running.")
|
||||
return
|
||||
|
||||
if self.server_port is None:
|
||||
self.server_port = self._find_available_port()
|
||||
|
||||
logger.info(f"Starting AgentOps local server on port {self.server_port}...")
|
||||
|
||||
self.server_process = multiprocessing.Process(
|
||||
target=_run_server,
|
||||
kwargs={"host": "127.0.0.1", "port": self.server_port, "use_reloader": False, "debug": False},
|
||||
daemon=self.daemon,
|
||||
name="AgentLightning-AgentOpsServer",
|
||||
)
|
||||
self.server_process.start()
|
||||
logger.info(
|
||||
f"AgentOps local server process (PID: {self.server_process.pid}) started, targeting port {self.server_port}."
|
||||
)
|
||||
time.sleep(0.5) # Brief wait for server to start up
|
||||
if not self.server_process.is_alive():
|
||||
logger.error(f"AgentOps local server failed to start or exited prematurely.")
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
if self.server_process and self.server_process.is_alive():
|
||||
return True
|
||||
return False
|
||||
|
||||
def stop(self):
|
||||
if self.server_process is not None and self.server_process.is_alive():
|
||||
logger.info(f"Stopping AgentOps local server (PID: {self.server_process.pid})...")
|
||||
self.server_process.terminate() # Send SIGTERM
|
||||
self.server_process.join(timeout=5) # Wait for clean exit
|
||||
if self.server_process.is_alive():
|
||||
logger.warning(
|
||||
f"AgentOps server (PID: {self.server_process.pid}) did not terminate gracefully, killing..."
|
||||
)
|
||||
self.server_process.kill() # Force kill
|
||||
self.server_process.join(timeout=10) # Wait for kill
|
||||
self.server_process = None
|
||||
logger.info(f"AgentOps local server stopped.")
|
||||
def export(self, *args: Any, **kwargs: Any) -> MetricExportResult:
|
||||
if _agentops_service_enabled:
|
||||
return super().export(*args, **kwargs) # type: ignore[reportUnknownMemberType]
|
||||
else:
|
||||
logger.info("AgentOps local server was not running or already stopped.")
|
||||
logger.debug("SwitchableOTLPMetricExporter is switched off, skipping export.")
|
||||
return MetricExportResult.SUCCESS
|
||||
|
||||
def get_port(self) -> int | None:
|
||||
# Check liveness again in case it died since start()
|
||||
if self.is_alive() and self.server_port is not None:
|
||||
return self.server_port
|
||||
# If called after server stopped or failed, port might be stale or None
|
||||
if self.server_port is not None and (self.server_process is None or not self.server_process.is_alive()):
|
||||
logger.warning(
|
||||
f"AgentOps server port {self.server_port} is stored, but server process is not alive. Returning stored port."
|
||||
)
|
||||
return self.server_port
|
||||
|
||||
class BypassableOTLPSpanExporter(LightningStoreOTLPExporter):
|
||||
"""
|
||||
OTLPSpanExporter with switchable service control.
|
||||
When `_agentops_service_enabled` is False, skip export and return success.
|
||||
|
||||
This is used instead of BypassableAuthenticatedOTLPExporter on legacy AgentOps versions.
|
||||
"""
|
||||
|
||||
def should_bypass(self) -> bool:
|
||||
return not _agentops_service_enabled
|
||||
|
||||
|
||||
class BypassableV3Client(V3Client):
|
||||
"""
|
||||
V3Client with toggleable authentication calls.
|
||||
Returns dummy auth response when `_agentops_service_enabled` is False.
|
||||
"""
|
||||
|
||||
# Temporary synchronous override of fetch_auth_token for mock purposes.
|
||||
def fetch_auth_token(self, *args: Any, **kwargs: Any) -> AuthTokenResponse: # type: ignore[override]
|
||||
if _agentops_service_enabled:
|
||||
return super().fetch_auth_token(*args, **kwargs) # type: ignore[override]
|
||||
else:
|
||||
logger.debug("SwitchableV3Client is switched off, skipping fetch_auth_token request.")
|
||||
return AuthTokenResponse(token="dummy", project_id="dummy")
|
||||
|
||||
|
||||
class BypassableV4Client(V4Client):
|
||||
"""
|
||||
V4Client with toggleable post requests.
|
||||
Returns dummy response when `_agentops_service_enabled` is False.
|
||||
"""
|
||||
|
||||
def post(self, *args: Any, **kwargs: Any) -> requests.Response:
|
||||
if _agentops_service_enabled:
|
||||
return super().post(*args, **kwargs)
|
||||
else:
|
||||
logger.debug("SwitchableV4Client is switched off, skipping post request.")
|
||||
response = requests.Response()
|
||||
response.status_code = 200
|
||||
response._content = b"{}"
|
||||
return response
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
It's unclear whether or not this file is useful.
|
||||
It seems that LiteLLM owns its own telemetry from their own entrance
|
||||
https://docs.litellm.ai/docs/observability/agentops_integration
|
||||
|
||||
[Related documentation](https://docs.litellm.ai/docs/observability/agentops_integration).
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Convenience decorators for building lightweight `LitAgent` implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
@@ -90,24 +92,25 @@ class FunctionalLitAgentFunc(Protocol[T_contra]):
|
||||
|
||||
|
||||
class FunctionalLitAgent(LitAgent[T]):
|
||||
"""A specialized LitAgent that wraps a function-based rollout that accepts
|
||||
dynamically a task input and a configured resource (LLM / prompt template / ...).
|
||||
"""Adapter that turns plain rollout functions into [`LitAgent`][agentlightning.LitAgent] instances.
|
||||
|
||||
This class allows users to define agent behavior using a simple function
|
||||
that takes task input and a resource, rather than implementing a full
|
||||
LitAgent subclass.
|
||||
The helper inspects the wrapped function to determine which resources to
|
||||
inject, allowing both synchronous and asynchronous callables to participate
|
||||
in the training loop without writing a dedicated subclass.
|
||||
"""
|
||||
|
||||
def __init__(self, rollout_func: FunctionalLitAgentFunc[T], *, strip_proxy: bool = True) -> None:
|
||||
"""
|
||||
Initialize the FunctionalLitAgent with a functional rollout function.
|
||||
"""Initialize the wrapper around a rollout function.
|
||||
|
||||
Args:
|
||||
rollout_func: A function that defines the agent's behavior.
|
||||
Can be sync or async, and can optionally accept a Rollout parameter.
|
||||
The function signature determines which resources are injected (llm, prompt_template, etc.).
|
||||
strip_proxy: Whether to strip the ProxyLLM resource into a LLM resource when the function accepts an llm parameter.
|
||||
Defaults to True.
|
||||
rollout_func: Callable that implements the rollout. It may be synchronous
|
||||
or asynchronous and can optionally receive a
|
||||
[`Rollout`][agentlightning.Rollout] alongside resources such as
|
||||
`llm` or `prompt_template`.
|
||||
strip_proxy: When ``True``, convert
|
||||
[`ProxyLLM`][agentlightning.ProxyLLM] inputs into
|
||||
[`LLM`][agentlightning.LLM] instances before calling the
|
||||
rollout function. Defaults to `True`.
|
||||
"""
|
||||
super().__init__()
|
||||
self._rollout_func = rollout_func
|
||||
@@ -138,12 +141,15 @@ class FunctionalLitAgent(LitAgent[T]):
|
||||
"""Execute a synchronous rollout using the wrapped function.
|
||||
|
||||
Args:
|
||||
task: The task input data.
|
||||
resources: Dictionary of named resources including LLMs.
|
||||
rollout: The rollout object with metadata.
|
||||
task: Task input data.
|
||||
resources: Mapping of named resources available to the agent.
|
||||
rollout: Rollout metadata provided by the runtime.
|
||||
|
||||
Returns:
|
||||
The result from the wrapped rollout function.
|
||||
Result produced by the wrapped rollout function.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the wrapped function is asynchronous.
|
||||
"""
|
||||
if self._is_async:
|
||||
raise RuntimeError(f"{self._rollout_func} is asynchronous. Use rollout_async instead.")
|
||||
@@ -155,12 +161,15 @@ class FunctionalLitAgent(LitAgent[T]):
|
||||
"""Execute an asynchronous rollout using the wrapped function.
|
||||
|
||||
Args:
|
||||
task: The task input data.
|
||||
resources: Dictionary of named resources including LLMs.
|
||||
rollout: The rollout object with metadata.
|
||||
task: Task input data.
|
||||
resources: Mapping of named resources available to the agent.
|
||||
rollout: Rollout metadata provided by the runtime.
|
||||
|
||||
Returns:
|
||||
The result from the wrapped rollout function.
|
||||
Result produced by the wrapped rollout coroutine.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the wrapped function is synchronous.
|
||||
"""
|
||||
if not self._is_async:
|
||||
raise RuntimeError(f"{self._rollout_func} is synchronous. Use rollout instead.")
|
||||
@@ -169,18 +178,19 @@ class FunctionalLitAgent(LitAgent[T]):
|
||||
return await self._rollout_func(task, **kwargs) # type: ignore
|
||||
|
||||
def _get_kwargs(self, resources: NamedResources, rollout: Rollout) -> Dict[str, Any]:
|
||||
"""Extract the kwargs needed for the rollout function based on its signature.
|
||||
"""Prepare keyword arguments expected by the wrapped rollout function.
|
||||
|
||||
Dynamically builds the kwargs dictionary by inspecting the function signature and
|
||||
|
||||
It dynamically builds the `kwargs` dictionary by inspecting the function signature and
|
||||
including only the parameters the function accepts. This allows flexible function
|
||||
signatures that can request any combination of: rollout, llm, and/or prompt_template.
|
||||
|
||||
Args:
|
||||
resources: Dictionary of named resources available for the rollout.
|
||||
rollout: The rollout object with metadata.
|
||||
resources: Mapping of named resources available for the rollout.
|
||||
rollout: Rollout metadata provided by the runtime.
|
||||
|
||||
Returns:
|
||||
A dictionary of kwargs to pass to the rollout function.
|
||||
Dictionary of keyword arguments to forward to the rollout function.
|
||||
"""
|
||||
|
||||
kwargs: Dict[str, Any] = {}
|
||||
@@ -194,19 +204,19 @@ class FunctionalLitAgent(LitAgent[T]):
|
||||
return kwargs
|
||||
|
||||
def _get_llm_resource(self, resources: NamedResources, rollout: Rollout) -> LLM:
|
||||
"""Extract the first LLM resource from the resources dictionary.
|
||||
"""Retrieve the first LLM resource from the available resources.
|
||||
|
||||
Strip the ProxyLLM resource into a LLM resource if needed.
|
||||
|
||||
Args:
|
||||
resources: Dictionary of named resources.
|
||||
rollout: The rollout object with metadata.
|
||||
resources: Mapping of named resources.
|
||||
rollout: Rollout metadata used when stripping proxy endpoints.
|
||||
|
||||
Returns:
|
||||
The first LLM resource found.
|
||||
First [`LLM`][agentlightning.LLM] resource encountered.
|
||||
|
||||
Raises:
|
||||
ValueError: If no LLM resource is found.
|
||||
ValueError: If no LLM resource is present.
|
||||
"""
|
||||
resource_found: LLM | None = None
|
||||
for name, resource in resources.items():
|
||||
@@ -225,17 +235,17 @@ class FunctionalLitAgent(LitAgent[T]):
|
||||
return resource_found
|
||||
|
||||
def _get_prompt_template_resource(self, resources: NamedResources, rollout: Rollout) -> PromptTemplate:
|
||||
"""Extract the first PromptTemplate resource from the resources dictionary.
|
||||
"""Retrieve the first prompt template resource from the available resources.
|
||||
|
||||
Args:
|
||||
resources: Dictionary of named resources.
|
||||
rollout: The rollout object with metadata. Not used in this method.
|
||||
resources: Mapping of named resources.
|
||||
rollout: Rollout metadata (unused).
|
||||
|
||||
Returns:
|
||||
The first PromptTemplate resource found.
|
||||
First [`PromptTemplate`][agentlightning.PromptTemplate] resource encountered.
|
||||
|
||||
Raises:
|
||||
ValueError: If no PromptTemplate resource is found.
|
||||
ValueError: If no prompt template resource is present.
|
||||
"""
|
||||
resource_found: PromptTemplate | None = None
|
||||
for name, resource in resources.items():
|
||||
@@ -253,21 +263,22 @@ class FunctionalLitAgent(LitAgent[T]):
|
||||
return resource_found
|
||||
|
||||
def _strip_proxy_helper(self, proxy_llm: LLM, rollout: Rollout) -> LLM:
|
||||
"""Strip the ProxyLLM resource into a concrete LLM resource.
|
||||
"""Convert [`ProxyLLM`][agentlightning.ProxyLLM] instances into concrete LLMs.
|
||||
|
||||
This method resolves ProxyLLM instances to their concrete LLM implementation
|
||||
It resolves ProxyLLM instances to their concrete LLM implementation
|
||||
by attaching the attempted rollout context. This is only used when the function
|
||||
signature accepts an 'llm' parameter and strip_proxy is True.
|
||||
signature accepts an `llm` parameter and strip_proxy is True.
|
||||
|
||||
Args:
|
||||
proxy_llm: The LLM resource, which may be a ProxyLLM.
|
||||
rollout: The rollout object with metadata.
|
||||
proxy_llm: Candidate LLM resource.
|
||||
rollout: Rollout metadata that provides rollout and attempt identifiers.
|
||||
|
||||
Returns:
|
||||
The concrete LLM resource.
|
||||
[`LLM`][agentlightning.LLM] with rollout context baked into the endpoint.
|
||||
|
||||
Raises:
|
||||
ValueError: If the rollout is not an AttemptedRollout (required for stripping ProxyLLM).
|
||||
ValueError: If the rollout is not an
|
||||
[`AttemptedRollout`][agentlightning.AttemptedRollout].
|
||||
"""
|
||||
|
||||
if not isinstance(proxy_llm, ProxyLLM):
|
||||
@@ -293,41 +304,37 @@ def llm_rollout(*, strip_proxy: bool = True) -> Callable[[LlmRolloutFunc[T]], Fu
|
||||
def llm_rollout(
|
||||
func: LlmRolloutFunc[T] | None = None, *, strip_proxy: bool = True
|
||||
) -> FunctionalLitAgent[T] | Callable[[LlmRolloutFunc[T]], FunctionalLitAgent[T]]:
|
||||
"""Create a FunctionalLitAgent from a function that takes (task, llm[, rollout]).
|
||||
|
||||
This decorator allows you to define an agent using a simple function
|
||||
instead of creating a full LitAgent subclass. The returned FunctionalLitAgent
|
||||
instance is callable, preserving the original function's behavior.
|
||||
"""Create a [`FunctionalLitAgent`][agentlightning.litagent.decorator.FunctionalLitAgent] for LLM-based rollouts.
|
||||
|
||||
Args:
|
||||
func: A function that defines the agent's behavior. Can be:
|
||||
- sync: (task, llm) -> result
|
||||
- sync with rollout: (task, llm, rollout) -> result
|
||||
- async: async (task, llm) -> result
|
||||
- async with rollout: async (task, llm, rollout) -> result
|
||||
strip_proxy: Whether to strip the ProxyLLM resource into a LLM resource.
|
||||
Defaults to True.
|
||||
func: Callable defining the agent's behaviour. Supported signatures include:
|
||||
|
||||
* `(task, llm) -> result`
|
||||
* `(task, llm, rollout) -> result`
|
||||
* `async (task, llm) -> result`
|
||||
* `async (task, llm, rollout) -> result`
|
||||
|
||||
strip_proxy: When `True`, convert proxy resources into concrete
|
||||
[`LLM`][agentlightning.LLM] instances before calling the
|
||||
function. Defaults to `True`.
|
||||
|
||||
Returns:
|
||||
A callable FunctionalLitAgent instance that preserves the original function's
|
||||
type hints and behavior while providing all agent functionality.
|
||||
[`FunctionalLitAgent`][agentlightning.litagent.decorator.FunctionalLitAgent] that
|
||||
wraps the supplied function.
|
||||
|
||||
Example:
|
||||
Examples:
|
||||
```python
|
||||
@llm_rollout
|
||||
def my_agent(task, llm):
|
||||
# Agent logic here
|
||||
return response
|
||||
return llm.endpoint
|
||||
|
||||
@llm_rollout(strip_proxy=False)
|
||||
def my_agent_no_strip(task, llm):
|
||||
# Agent logic here
|
||||
return response
|
||||
return llm.model
|
||||
|
||||
# Function is still callable with original behavior
|
||||
result = my_agent(task, llm)
|
||||
|
||||
# Agent methods are also available
|
||||
result = my_agent.rollout(task, resources, rollout)
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(f: LlmRolloutFunc[T]) -> FunctionalLitAgent[T]:
|
||||
@@ -343,19 +350,20 @@ def llm_rollout(
|
||||
|
||||
|
||||
def _validate_llm_rollout_func(func: Any) -> TypeGuard[LlmRolloutFunc[Any]]:
|
||||
"""Validate the function signature of a LLM rollout function.
|
||||
"""Validate the function signature of an LLM rollout function.
|
||||
|
||||
Ensures the function follows the expected pattern for LLM-based rollouts:
|
||||
|
||||
- Must have at least 2 parameters
|
||||
- First parameter must be named 'task'
|
||||
- Must have a parameter named 'llm'
|
||||
- Optionally can have a 'rollout' parameter
|
||||
|
||||
Args:
|
||||
func: The function to validate.
|
||||
func: Function to inspect.
|
||||
|
||||
Returns:
|
||||
True if the function signature is valid.
|
||||
`True` when the signature matches the supported patterns.
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature does not match the expected pattern.
|
||||
@@ -383,36 +391,34 @@ def prompt_rollout() -> Callable[[PromptRolloutFunc[T]], FunctionalLitAgent[T]]:
|
||||
def prompt_rollout(
|
||||
func: PromptRolloutFunc[T] | None = None,
|
||||
) -> FunctionalLitAgent[T] | Callable[[PromptRolloutFunc[T]], FunctionalLitAgent[T]]:
|
||||
"""Create a FunctionalLitAgent from a function that takes (task, prompt_template[, rollout]).
|
||||
"""Create a [`FunctionalLitAgent`][agentlightning.litagent.decorator.FunctionalLitAgent] for prompt-based rollouts.
|
||||
|
||||
This decorator is designed for agents that work with tunable prompt templates. It enables
|
||||
a workflow where algorithms manage and optimize the prompt template, while agents consume
|
||||
the template to perform rollouts. This is particularly useful for prompt optimization scenarios.
|
||||
|
||||
Args:
|
||||
func: A function that defines the agent's behavior. Can be:
|
||||
- sync: (task, prompt_template) -> result
|
||||
- sync with rollout: (task, prompt_template, rollout) -> result
|
||||
- async: async (task, prompt_template) -> result
|
||||
- async with rollout: async (task, prompt_template, rollout) -> result
|
||||
func: Callable defining the agent's behavior. Supported signatures include:
|
||||
|
||||
* `(task, prompt_template) -> result`
|
||||
* `(task, prompt_template, rollout) -> result`
|
||||
* `async (task, prompt_template) -> result`
|
||||
* `async (task, prompt_template, rollout) -> result`
|
||||
|
||||
Returns:
|
||||
A callable FunctionalLitAgent instance that preserves the original function's
|
||||
type hints and behavior while providing all agent functionality.
|
||||
[`FunctionalLitAgent`][agentlightning.litagent.decorator.FunctionalLitAgent] that
|
||||
wraps the supplied function.
|
||||
|
||||
Example:
|
||||
Examples:
|
||||
```python
|
||||
@prompt_rollout
|
||||
def my_agent(task, prompt_template):
|
||||
# Use the prompt template to generate a response
|
||||
messages = prompt_template.format(task=task.input)
|
||||
# ... perform rollout with the formatted prompt
|
||||
return response
|
||||
return messages
|
||||
|
||||
# Function is still callable with original behavior
|
||||
result = my_agent(task, prompt_template)
|
||||
|
||||
# Agent methods are also available
|
||||
result = my_agent.rollout(task, resources, rollout)
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(f: PromptRolloutFunc[T]) -> FunctionalLitAgent[T]:
|
||||
@@ -429,16 +435,17 @@ def _validate_prompt_rollout_func(func: Any) -> TypeGuard[PromptRolloutFunc[Any]
|
||||
"""Validate the function signature of a prompt rollout function.
|
||||
|
||||
Ensures the function follows the expected pattern for prompt-template-based rollouts:
|
||||
|
||||
- Must have at least 2 parameters
|
||||
- First parameter must be named 'task'
|
||||
- Must have a parameter named 'prompt_template'
|
||||
- Optionally can have a 'rollout' parameter
|
||||
|
||||
Args:
|
||||
func: The function to validate.
|
||||
func: Function to inspect.
|
||||
|
||||
Returns:
|
||||
True if the function signature is valid.
|
||||
`True` when the signature matches the supported patterns.
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature does not match the expected pattern.
|
||||
@@ -456,23 +463,30 @@ def _validate_prompt_rollout_func(func: Any) -> TypeGuard[PromptRolloutFunc[Any]
|
||||
|
||||
|
||||
def rollout(func: Union[LlmRolloutFunc[T], PromptRolloutFunc[T], Callable[..., Any]]) -> FunctionalLitAgent[T]:
|
||||
"""Create a LitAgent from a function, automatically detecting the appropriate type.
|
||||
"""Create a [`FunctionalLitAgent`][agentlightning.litagent.decorator.FunctionalLitAgent] from an arbitrary rollout function.
|
||||
|
||||
This function inspects the provided callable and creates the appropriate
|
||||
agent type based on its signature. It supports both LLM-based and prompt-template-based
|
||||
agents. The returned agent instance is callable, preserving the original function's
|
||||
behavior and type hints.
|
||||
|
||||
See [`llm_rollout`][agentlightning.litagent.decorator.llm_rollout] and
|
||||
[`prompt_rollout`][agentlightning.litagent.decorator.prompt_rollout] for more details.
|
||||
|
||||
Args:
|
||||
func: A function that defines the agent's behavior. Supported signatures:
|
||||
- (task, llm[, rollout]) for LLM-based agents
|
||||
- (task, prompt_template[, rollout]) for prompt-template-based agents
|
||||
func: Callable that implements the rollout. Supported signatures:
|
||||
|
||||
- `[async ](task, llm[, rollout])` for LLM-based agents
|
||||
- `[async ](task, prompt_template[, rollout])` for prompt-template-based agents
|
||||
|
||||
The supported output types of `func` is same as the return type of [`rollout`][agentlightning.LitAgent.rollout].
|
||||
|
||||
Returns:
|
||||
A callable FunctionalLitAgent instance that preserves the original function's
|
||||
type hints and behavior while providing all agent functionality.
|
||||
[`FunctionalLitAgent`][agentlightning.litagent.decorator.FunctionalLitAgent] that
|
||||
wraps the supplied function.
|
||||
|
||||
Example:
|
||||
Examples:
|
||||
```python
|
||||
# LLM-based agent
|
||||
@rollout
|
||||
def my_llm_agent(task, llm):
|
||||
@@ -495,6 +509,7 @@ def rollout(func: Union[LlmRolloutFunc[T], PromptRolloutFunc[T], Callable[..., A
|
||||
|
||||
# Agent methods are also available
|
||||
result = my_llm_agent.rollout(task, resources, rollout)
|
||||
```
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the function signature doesn't match any known patterns.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Base abstractions for building agents that plug into Agent Lightning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
@@ -11,8 +13,8 @@ from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar
|
||||
from agentlightning.types import NamedResources, Rollout, RolloutRawResult, Task
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agentlightning.runner import BaseRunner
|
||||
from agentlightning.tracer import BaseTracer
|
||||
from agentlightning.runner import Runner
|
||||
from agentlightning.tracer import Tracer
|
||||
from agentlightning.trainer import Trainer
|
||||
|
||||
|
||||
@@ -26,32 +28,38 @@ __all__ = [
|
||||
|
||||
|
||||
def is_v0_1_rollout_api(func: Callable[..., Any]) -> bool:
|
||||
"""Check if the rollout API is v0.1.
|
||||
Inspect the function signature to see if it has a rollout_id parameter.
|
||||
"""Return `True` when the rollout function uses the deprecated v0.1 signature.
|
||||
|
||||
The helper inspects the callable's signature to detect whether a `rollout_id`
|
||||
parameter is present, which indicates the legacy API.
|
||||
|
||||
Args:
|
||||
func: The function to check.
|
||||
func: Function to analyze.
|
||||
|
||||
Returns:
|
||||
`True` if the callable exposes a `rollout_id` parameter.
|
||||
"""
|
||||
return "rollout_id" in inspect.signature(func).parameters
|
||||
|
||||
|
||||
class LitAgent(Generic[T]):
|
||||
"""Base class for the training and validation logic of an agent.
|
||||
"""Base class for implementing agent rollouts.
|
||||
|
||||
Developers should subclass this class and implement the rollout methods
|
||||
to define the agent's behavior for a single task. The agent's logic
|
||||
is completely decoupled from the server communication and training
|
||||
infrastructure.
|
||||
Subclasses override the rollout methods to process tasks while the trainer and
|
||||
runner infrastructure manages orchestration, tracing, and persistence.
|
||||
"""
|
||||
|
||||
def __init__(self, *, trained_agents: Optional[str] = None) -> None: # FIXME: str | None won't work for cli
|
||||
"""
|
||||
Initialize the LitAgent.
|
||||
"""Initialize the agent instance.
|
||||
|
||||
Args:
|
||||
trained_agents: Optional string representing the trained agents.
|
||||
This can be used to track which agents have been trained by this instance.
|
||||
Deprecated. Configure `agent_match` in adapter instead.
|
||||
trained_agents: Optional identifier used by legacy tooling to mark trained
|
||||
agents.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
The `trained_agents` flag is deprecated. Configure `agent_match` in the adapter
|
||||
layer instead. See [`TracerTraceToTriplet`][agentlightning.TracerTraceToTriplet]
|
||||
for more details.
|
||||
"""
|
||||
if trained_agents is not None:
|
||||
warnings.warn(
|
||||
@@ -62,15 +70,12 @@ class LitAgent(Generic[T]):
|
||||
self.trained_agents = trained_agents
|
||||
|
||||
self._trainer_ref: weakref.ReferenceType[Trainer] | None = None
|
||||
self._runner_ref: weakref.ReferenceType[BaseRunner[T]] | None = None
|
||||
self._runner_ref: weakref.ReferenceType[Runner[T]] | None = None
|
||||
|
||||
def is_async(self) -> bool:
|
||||
"""
|
||||
Check if the agent implements asynchronous rollout methods.
|
||||
Override this property for customized async detection logic.
|
||||
"""Return `True` when the agent overrides any asynchronous rollout methods.
|
||||
|
||||
Returns:
|
||||
True if the agent has custom async rollout methods, False otherwise.
|
||||
Override this method for customized async detection logic.
|
||||
"""
|
||||
return (
|
||||
(
|
||||
@@ -85,21 +90,15 @@ class LitAgent(Generic[T]):
|
||||
)
|
||||
|
||||
def set_trainer(self, trainer: Trainer) -> None:
|
||||
"""
|
||||
Set the trainer for this agent.
|
||||
"""Attach the trainer responsible for orchestration.
|
||||
|
||||
Args:
|
||||
trainer: The Trainer instance that will handle training and validation.
|
||||
trainer: [`Trainer`][agentlightning.Trainer] that manages the agent.
|
||||
"""
|
||||
self._trainer_ref = weakref.ref(trainer)
|
||||
|
||||
def get_trainer(self) -> Trainer:
|
||||
"""
|
||||
Get the trainer for this agent.
|
||||
|
||||
Returns:
|
||||
The Trainer instance associated with this agent.
|
||||
"""
|
||||
"""Return the trainer associated with this agent."""
|
||||
if self._trainer_ref is None:
|
||||
raise ValueError("Trainer has not been set for this agent.")
|
||||
trainer = self._trainer_ref()
|
||||
@@ -109,42 +108,31 @@ class LitAgent(Generic[T]):
|
||||
|
||||
@property
|
||||
def trainer(self) -> Trainer:
|
||||
"""Convenient shortcut of self.get_trainer()."""
|
||||
"""Return the trainer associated with this agent."""
|
||||
return self.get_trainer()
|
||||
|
||||
def get_tracer(self) -> BaseTracer:
|
||||
"""
|
||||
Get the tracer for this agent.
|
||||
|
||||
Returns:
|
||||
The BaseTracer instance associated with this agent.
|
||||
"""
|
||||
def get_tracer(self) -> Tracer:
|
||||
"""Return the tracer configured for this agent."""
|
||||
if hasattr(self.runner, "tracer"):
|
||||
return self.runner.tracer # type: ignore
|
||||
else:
|
||||
return self.trainer.tracer
|
||||
|
||||
@property
|
||||
def tracer(self) -> BaseTracer:
|
||||
"""Convenient shortcut of self.get_tracer()."""
|
||||
def tracer(self) -> Tracer:
|
||||
"""Return the tracer configured for this agent."""
|
||||
return self.get_tracer()
|
||||
|
||||
def set_runner(self, runner: BaseRunner[T]) -> None:
|
||||
"""
|
||||
Set the runner for this agent.
|
||||
def set_runner(self, runner: Runner[T]) -> None:
|
||||
"""Attach the runner responsible for executing rollouts.
|
||||
|
||||
Args:
|
||||
runner: The runner instance that will handle the execution of rollouts.
|
||||
runner: [`Runner`][agentlightning.Runner] coordinating execution.
|
||||
"""
|
||||
self._runner_ref = weakref.ref(runner)
|
||||
|
||||
def get_runner(self) -> BaseRunner[T]:
|
||||
"""
|
||||
Get the runner for this agent.
|
||||
|
||||
Returns:
|
||||
The runner instance associated with this agent.
|
||||
"""
|
||||
def get_runner(self) -> Runner[T]:
|
||||
"""Return the runner responsible for executing rollouts."""
|
||||
if self._runner_ref is None:
|
||||
raise ValueError("Runner has not been set for this agent.")
|
||||
runner = self._runner_ref()
|
||||
@@ -153,159 +141,111 @@ class LitAgent(Generic[T]):
|
||||
return runner
|
||||
|
||||
@property
|
||||
def runner(self) -> BaseRunner[T]:
|
||||
"""Convenient shortcut of self.get_runner()."""
|
||||
def runner(self) -> Runner[T]:
|
||||
"""Return the runner responsible for executing rollouts."""
|
||||
return self.get_runner()
|
||||
|
||||
def on_rollout_start(self, task: Task, runner: BaseRunner[T], tracer: BaseTracer) -> None:
|
||||
"""Hook called immediately before a rollout begins.
|
||||
def on_rollout_start(self, task: Task, runner: Runner[T], tracer: Tracer) -> None:
|
||||
"""Hook invoked immediately before a rollout begins.
|
||||
|
||||
Deprecated in favor of `on_rollout_start` in the `Hook` interface.
|
||||
Subclasses can override this method to implement custom logic such as logging,
|
||||
metric collection, or resource setup. The default implementation is a no-op.
|
||||
|
||||
Args:
|
||||
task: The :class:`Task` object that will be processed.
|
||||
runner: The :class:`BaseRunner` managing the rollout.
|
||||
tracer: The tracer instance associated with the runner.
|
||||
task: [`Task`][agentlightning.Task] that will be processed.
|
||||
runner: [`Runner`][agentlightning.Runner] managing the rollout.
|
||||
tracer: [`Tracer`][agentlightning.Tracer] associated with the runner.
|
||||
|
||||
Subclasses can override this method to implement custom logic such as
|
||||
logging, metric collection, or resource setup. By default, this is a
|
||||
no-op.
|
||||
!!! warning "Deprecated"
|
||||
Override [`Hook.on_rollout_start`][agentlightning.Hook.on_rollout_start]
|
||||
instead of this method when extending agents.
|
||||
"""
|
||||
|
||||
def on_rollout_end(self, task: Task, rollout: Rollout, runner: BaseRunner[T], tracer: BaseTracer) -> None:
|
||||
"""Hook called after a rollout completes.
|
||||
def on_rollout_end(self, task: Task, rollout: Rollout, runner: Runner[T], tracer: Tracer) -> None:
|
||||
"""Hook invoked after a rollout completes.
|
||||
|
||||
Deprecated in favor of `on_rollout_end` in the `Hook` interface.
|
||||
Subclasses can override this method for cleanup or additional logging. The default
|
||||
implementation is a no-op.
|
||||
|
||||
Args:
|
||||
task: The :class:`Task` object that was processed.
|
||||
rollout: The resulting :class:`Rollout` object.
|
||||
runner: The :class:`BaseRunner` managing the rollout.
|
||||
tracer: The tracer instance associated with the runner.
|
||||
task: [`Task`][agentlightning.Task] that was processed.
|
||||
rollout: Resulting [`Rollout`][agentlightning.Rollout].
|
||||
runner: [`Runner`][agentlightning.Runner] managing the rollout.
|
||||
tracer: [`Tracer`][agentlightning.Tracer] associated with the runner.
|
||||
|
||||
Subclasses can override this method for cleanup or additional
|
||||
logging. By default, this is a no-op.
|
||||
!!! warning "Deprecated"
|
||||
Override [`Hook.on_rollout_end`][agentlightning.Hook.on_rollout_end]
|
||||
instead of this method when extending agents.
|
||||
"""
|
||||
|
||||
def rollout(self, task: T, resources: NamedResources, rollout: Rollout) -> RolloutRawResult:
|
||||
"""Main entry point for executing a rollout.
|
||||
"""Execute a rollout synchronously.
|
||||
|
||||
This method determines whether to call the synchronous or
|
||||
asynchronous rollout method based on the agent's implementation.
|
||||
|
||||
If you don't wish to implement both training rollout and validation
|
||||
rollout separately, you can just implement `rollout` which will work for both.
|
||||
|
||||
Args:
|
||||
task: The task object received from the server, containing the
|
||||
input data and metadata.
|
||||
resources: A dictionary of named resources (e.g., LLMs, prompt
|
||||
templates) for the agent to use.
|
||||
rollout: The full rollout object, please avoid from directly modifying it.
|
||||
Most agents should only use `task` and `resources`. Use `rollout`
|
||||
only if you need to access metadata like `rollout_id`.
|
||||
task: Task payload provided by the scheduler.
|
||||
resources: Mapping of named resources (for example LLMs or prompt templates).
|
||||
rollout: Rollout metadata. Avoid mutating this object directly unless a
|
||||
subclass needs to override defaults.
|
||||
|
||||
Returns:
|
||||
The result of the rollout, which can be one of:
|
||||
- None. The tracing should be handled by the agent runner.
|
||||
- A float representing the final reward.
|
||||
- A list of `Triplet` objects for detailed, step-by-step feedback.
|
||||
- A list of `ReadableSpan` objects for OpenTelemetry tracing.
|
||||
- A list of dictionaries for any trace spans.
|
||||
- A complete `Rollout` object for full control over reporting.
|
||||
One of the following values:
|
||||
|
||||
* `None` when tracing is handled by the runner.
|
||||
* `float` representing the final reward.
|
||||
* `List[ReadableSpan]` with OpenTelemetry spans.
|
||||
* `List[Span]` with Agent Lightning spans.
|
||||
"""
|
||||
raise NotImplementedError("Agents must implement the `rollout` method.")
|
||||
|
||||
async def rollout_async(self, task: T, resources: NamedResources, rollout: Rollout) -> RolloutRawResult:
|
||||
"""Asynchronous version of the main rollout method.
|
||||
|
||||
This method determines whether to call the synchronous or
|
||||
asynchronous rollout method based on the agent's implementation.
|
||||
"""Execute a rollout asynchronously.
|
||||
|
||||
Args:
|
||||
task: The task object received from the server, containing the
|
||||
input data and metadata.
|
||||
resources: A dictionary of named resources (e.g., LLMs, prompt
|
||||
templates) for the agent to use.
|
||||
rollout: The full rollout object, please avoid from directly modifying it.
|
||||
Most agents should only use `task` and `resources`. Use `rollout`
|
||||
only if you need to access metadata like `rollout_id`.
|
||||
task: Task payload provided by the scheduler.
|
||||
resources: Mapping of named resources (for example LLMs or prompt templates).
|
||||
rollout: Rollout metadata. Avoid mutating this object directly unless a
|
||||
subclass needs to override defaults.
|
||||
|
||||
Returns:
|
||||
The result of the rollout, which can be one of:
|
||||
- None. The tracing should be handled by the agent runner.
|
||||
- A float representing the final reward.
|
||||
- A list of `Triplet` objects for detailed, step-by-step feedback.
|
||||
- A list of `ReadableSpan` objects for OpenTelemetry tracing.
|
||||
- A list of dictionaries for any trace spans.
|
||||
- A complete `Rollout` object for full control over reporting.
|
||||
Same possible return values as
|
||||
[`rollout`][agentlightning.LitAgent.rollout].
|
||||
"""
|
||||
raise NotImplementedError("Agents must implement the `rollout_async` method for async operations.")
|
||||
|
||||
def training_rollout(self, task: T, resources: NamedResources, rollout: Rollout) -> RolloutRawResult:
|
||||
"""Defines the agent's behavior for a single training task.
|
||||
"""Process a single training task synchronously.
|
||||
|
||||
This method should contain the logic for how the agent processes an
|
||||
input, uses the provided resources (like LLMs or prompts), and
|
||||
produces a result.
|
||||
|
||||
Args:
|
||||
task: The task object received from the server, containing the
|
||||
input data and metadata.
|
||||
resources: A dictionary of named resources (e.g., LLMs, prompt
|
||||
templates) for the agent to use.
|
||||
rollout: The full rollout object, please avoid from directly modifying it.
|
||||
By default, this method delegates to
|
||||
[`rollout`][agentlightning.LitAgent.rollout].
|
||||
"""
|
||||
return self.rollout(task, resources, rollout)
|
||||
|
||||
def validation_rollout(self, task: T, resources: NamedResources, rollout: Rollout) -> RolloutRawResult:
|
||||
"""Defines the agent's behavior for a single validation task.
|
||||
"""Process a single validation task synchronously.
|
||||
|
||||
By default, this method redirects to `training_rollout`. Override it
|
||||
if the agent should behave differently during validation.
|
||||
|
||||
Args:
|
||||
task: The task object received from the server, containing the
|
||||
input data and metadata.
|
||||
resources: A dictionary of named resources for the agent to use.
|
||||
rollout: The full rollout object, avoid from modifying it.
|
||||
|
||||
Returns:
|
||||
The result of the validation rollout. See `rollout` for
|
||||
possible return types.
|
||||
Override this method when validation should differ from training. The default
|
||||
implementation delegates to
|
||||
[`training_rollout`][agentlightning.LitAgent.training_rollout].
|
||||
"""
|
||||
return self.rollout(task, resources, rollout)
|
||||
|
||||
async def training_rollout_async(self, task: T, resources: NamedResources, rollout: Rollout) -> RolloutRawResult:
|
||||
"""Asynchronous version of `training_rollout`.
|
||||
"""Process a single training task asynchronously.
|
||||
|
||||
This method should be implemented by agents that perform asynchronous
|
||||
operations (e.g., non-blocking I/O, concurrent API calls).
|
||||
|
||||
Args:
|
||||
task: The task object received from the server.
|
||||
resources: A dictionary of named resources for the agent to use.
|
||||
rollout: The full rollout object, avoid from modifying it.
|
||||
|
||||
Returns:
|
||||
The result of the asynchronous training rollout. See `rollout` for
|
||||
possible return types.
|
||||
By default, this method delegates to
|
||||
[`rollout_async`][agentlightning.LitAgent.rollout_async].
|
||||
"""
|
||||
return await self.rollout_async(task, resources, rollout)
|
||||
|
||||
async def validation_rollout_async(self, task: T, resources: NamedResources, rollout: Rollout) -> RolloutRawResult:
|
||||
"""Asynchronous version of `validation_rollout`.
|
||||
"""Process a single validation task asynchronously.
|
||||
|
||||
By default, this method redirects to `training_rollout_async`.
|
||||
Override it for different asynchronous validation behavior.
|
||||
|
||||
Args:
|
||||
task: The task object received from the server.
|
||||
resources: A dictionary of named resources for the agent to use.
|
||||
rollout: The full rollout object, avoid from modifying it.
|
||||
|
||||
Returns:
|
||||
The result of the asynchronous validation rollout. See `rollout` for
|
||||
possible return types.
|
||||
Override this method when validation should differ from training. The default
|
||||
implementation delegates to
|
||||
[`training_rollout_async`][agentlightning.LitAgent.training_rollout_async].
|
||||
"""
|
||||
return await self.rollout_async(task, resources, rollout)
|
||||
|
||||
+941
-275
File diff suppressed because it is too large
Load Diff
+363
-13
@@ -1,20 +1,370 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["configure_logger"]
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import warnings
|
||||
from logging.config import dictConfig
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
__all__ = ["setup", "configure_logger", "setup_module"]
|
||||
|
||||
|
||||
def configure_logger(level: int = logging.INFO, name: str = "agentlightning") -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
logger.handlers.clear() # clear existing handlers
|
||||
"""Create or reset a namespaced logger with a consistent console format.
|
||||
|
||||
# log to stdout
|
||||
handler = logging.StreamHandler()
|
||||
handler.setLevel(level)
|
||||
formatter = logging.Formatter("%(asctime)s [%(levelname)s] (Process-%(process)d %(name)s) %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(level)
|
||||
logger.propagate = False # prevent double logging
|
||||
return logger
|
||||
This helper clears any previously attached handlers before binding a single
|
||||
`StreamHandler` that writes to standard output. The resulting logger does
|
||||
not propagate to the root logger, preventing duplicate log emission when
|
||||
applications compose multiple logging configurations.
|
||||
|
||||
!!! danger
|
||||
|
||||
This function is deprecated in favor of [`setup_logging`][agentlightning.setup_logging].
|
||||
|
||||
Args:
|
||||
level: Logging level applied both to the logger and the installed
|
||||
handler. Defaults to `logging.INFO`.
|
||||
name: Dotted path for the logger instance. Defaults to
|
||||
`"agentlightning"`.
|
||||
|
||||
Returns:
|
||||
Configured logger instance ready for immediate use.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from agentlightning import configure_logger
|
||||
|
||||
logger = configure_logger(level=logging.INFO)
|
||||
logger.info("agent-lightning is ready!")
|
||||
```
|
||||
"""
|
||||
warnings.warn("This function is deprecated in favor of `setup_logging`.", DeprecationWarning, stacklevel=2)
|
||||
|
||||
return setup_module(level=level, name=name, console=True, color=True, propagate=False)
|
||||
|
||||
|
||||
DEFAULT_FORMAT = "%(asctime)s [%(levelname)s] (Process-%(process)d %(name)s) %(message)s"
|
||||
DATE_FORMAT = "%H:%M:%S"
|
||||
|
||||
|
||||
def _to_level_value(lvl: int | str) -> int:
|
||||
if isinstance(lvl, int):
|
||||
return lvl
|
||||
val = getattr(logging, str(lvl).upper(), None)
|
||||
if val is None:
|
||||
raise ValueError(f"Invalid log level: {lvl}")
|
||||
return val
|
||||
|
||||
|
||||
def _ensure_file_handler(
|
||||
logger: logging.Logger,
|
||||
filename: str,
|
||||
*,
|
||||
level: int,
|
||||
formatter: Optional[logging.Formatter],
|
||||
) -> None:
|
||||
"""Attach a FileHandler to `logger` for `filename` if it doesn't already exist."""
|
||||
abspath = os.path.abspath(filename)
|
||||
|
||||
# Avoid duplicates
|
||||
for h in logger.handlers:
|
||||
if isinstance(h, logging.FileHandler) and getattr(h, "baseFilename", None) == abspath:
|
||||
return
|
||||
|
||||
# Ensure directory exists
|
||||
dirname = os.path.dirname(abspath)
|
||||
if dirname:
|
||||
os.makedirs(dirname, exist_ok=True)
|
||||
|
||||
fh = logging.FileHandler(abspath, encoding="utf-8")
|
||||
fh.setLevel(level)
|
||||
if formatter is not None:
|
||||
fh.setFormatter(formatter)
|
||||
else:
|
||||
fh.setFormatter(logging.Formatter(DEFAULT_FORMAT, DATE_FORMAT))
|
||||
|
||||
logger.addHandler(fh)
|
||||
|
||||
|
||||
def setup(
|
||||
level: int | str = "INFO",
|
||||
*,
|
||||
console: bool = True,
|
||||
color: bool | Dict[str, Any] = True,
|
||||
propagate: bool = False,
|
||||
disable_existing_loggers: bool = False,
|
||||
capture_warnings: bool = False,
|
||||
submodule_levels: Optional[dict[str, int | str]] = None,
|
||||
extra_handlers: Optional[list[logging.Handler]] = None,
|
||||
formatter: Optional[logging.Formatter] = None,
|
||||
apply_to: Optional[list[str]] = None,
|
||||
files: Optional[str | dict[str, str]] = None,
|
||||
) -> None:
|
||||
"""Configures logging for the `agentlightning` logger hierarchy.
|
||||
|
||||
This function provides a one-stop setup utility for configuring the
|
||||
`agentlightning` root logger and optionally its submodules or external
|
||||
loggers. It supports console logging, colored rich output, per-submodule
|
||||
log levels, and optional handler/formatter injection.
|
||||
|
||||
The setup is intentionally isolated: it does not modify the global root
|
||||
logger or loggers belonging to other libraries unless explicitly directed
|
||||
via `apply_to`.
|
||||
|
||||
Args:
|
||||
level:
|
||||
Logging level for the base `agentlightning` logger. Accepts either
|
||||
an integer (e.g., `logging.DEBUG`) or a string level name
|
||||
(e.g., `"INFO"`). Defaults to `"INFO"`.
|
||||
console:
|
||||
Whether to attach a console handler to the logger. Defaults to
|
||||
`True`.
|
||||
color:
|
||||
Enables rich-formatted output using `RichHandler` when `True`
|
||||
or a configuration dict. If `False`, a plain text formatter is
|
||||
used instead. Defaults to `True`.
|
||||
propagate:
|
||||
Whether `agentlightning` logs should propagate to ancestor
|
||||
loggers. Defaults to `False`.
|
||||
disable_existing_loggers:
|
||||
Passed to `logging.config.dictConfig`. If `True`, disables all
|
||||
existing configured loggers before applying this configuration.
|
||||
Defaults to `False`.
|
||||
capture_warnings:
|
||||
If `True`, redirects Python `warnings` emitted via the `warnings`
|
||||
module into the logging system. Defaults to `False`.
|
||||
submodule_levels:
|
||||
Mapping of submodule logger names to logging levels. If a specified
|
||||
submodule level is more verbose than the base level, a warning is emitted.
|
||||
extra_handlers:
|
||||
A list of user-provided handlers to attach to the `agentlightning` logger.
|
||||
Handlers are added idempotently; duplicates are not reattached.
|
||||
formatter:
|
||||
A formatter to apply to any handler under `agentlightning` that does not
|
||||
already have one assigned. Useful for customizing output without overwriting
|
||||
formatters on custom handlers.
|
||||
apply_to:
|
||||
A list of additional logger names to configure identically to
|
||||
`agentlightning` base logger. Their handlers are replaced with copies of the base
|
||||
handlers, and propagation is disabled to avoid duplicate log emission.
|
||||
files:
|
||||
If a string, attach a FileHandler to the base `agentlightning` logger.
|
||||
If a dict, for each `(logger_name, filename)` pair, attach a FileHandler
|
||||
directly to that logger.
|
||||
Each file handler should use the logger's effective level at creation.
|
||||
|
||||
Notes:
|
||||
* On Windows, this function forces UTF-8 mode in the console to prevent
|
||||
issues with rich output or special characters.
|
||||
* Submodule loggers can generate records below the handler's emission
|
||||
threshold. Whether such records appear depends on both the logger's
|
||||
level and the handler's level.
|
||||
* `apply_to` loggers inherit the same handlers but do not propagate
|
||||
upward, yielding isolated, consistent behavior.
|
||||
|
||||
Examples:
|
||||
Basic setup:
|
||||
|
||||
>>> setup()
|
||||
|
||||
Enabling debug mode with no color:
|
||||
|
||||
>>> setup(level="DEBUG", color=False)
|
||||
|
||||
Overriding specific submodule levels:
|
||||
|
||||
>>> setup(submodule_levels={"agentlightning.io": "DEBUG"})
|
||||
|
||||
Attaching an additional file handler:
|
||||
|
||||
>>> fh = logging.FileHandler("app.log")
|
||||
>>> setup(extra_handlers=[fh])
|
||||
"""
|
||||
# Ensure UTF-8 encoding on Windows consoles
|
||||
# Note: This change does not fully represent support for execution under the windows system.
|
||||
# It only fixes console printing issues caused by special characters.
|
||||
# TODO: More comprehensive Windows support may be needed in the future.
|
||||
if platform.system() == "Windows":
|
||||
os.environ["PYTHONUTF8"] = "1"
|
||||
|
||||
base_logger = setup_module(
|
||||
level,
|
||||
name="agentlightning",
|
||||
console=console,
|
||||
color=color,
|
||||
propagate=propagate,
|
||||
disable_existing_loggers=disable_existing_loggers,
|
||||
)
|
||||
|
||||
base_level_value = base_logger.level
|
||||
|
||||
# Apply user-provided formatter (only to handlers without one,
|
||||
# so we don't clobber custom extra_handlers)
|
||||
if formatter is not None:
|
||||
for h in base_logger.handlers:
|
||||
if h.formatter is None:
|
||||
h.setFormatter(formatter)
|
||||
|
||||
# Attach user-provided handler(s) if any, idempotently
|
||||
if extra_handlers:
|
||||
for h in extra_handlers:
|
||||
if h not in base_logger.handlers:
|
||||
base_logger.addHandler(h)
|
||||
|
||||
# Per-submodule levels
|
||||
if submodule_levels:
|
||||
for name, lvl in submodule_levels.items():
|
||||
sub_level = _to_level_value(lvl)
|
||||
|
||||
# Emit a warning if submodule level is lower (more verbose) than the global/base level
|
||||
if sub_level < base_level_value:
|
||||
base_logger.warning(
|
||||
"Submodule logger '%s' level %s (%s) is more verbose than base "
|
||||
"logger level %s (%s). Records below the base level may still be "
|
||||
"filtered out by handlers depending on their own levels.",
|
||||
name,
|
||||
lvl,
|
||||
sub_level,
|
||||
logging.getLevelName(base_level_value),
|
||||
base_level_value,
|
||||
)
|
||||
|
||||
# The logger will *create* records down to the logger's level, but a handler
|
||||
# with a higher level will still drop anything below its own threshold.
|
||||
# Effective emission is gated by both: record.level >= logger.level AND handler.level.
|
||||
logging.getLogger(name).setLevel(lvl)
|
||||
|
||||
# Attach file handlers if requested
|
||||
if files is not None:
|
||||
if isinstance(files, str):
|
||||
# Single file for the entire `agentlightning` hierarchy.
|
||||
_ensure_file_handler(
|
||||
logger=base_logger,
|
||||
filename=files,
|
||||
level=base_level_value,
|
||||
formatter=formatter,
|
||||
)
|
||||
else:
|
||||
# Per-logger files
|
||||
for logger_name, filename in files.items():
|
||||
lg = logging.getLogger(logger_name)
|
||||
# Use the logger's *effective* level at creation time
|
||||
effective_level = lg.getEffectiveLevel()
|
||||
_ensure_file_handler(
|
||||
logger=lg,
|
||||
filename=filename,
|
||||
level=effective_level,
|
||||
formatter=formatter,
|
||||
)
|
||||
|
||||
# Optionally apply the same handler setup to other loggers outside this module
|
||||
if apply_to:
|
||||
for name in apply_to:
|
||||
lg = logging.getLogger(name)
|
||||
# This removes any existing handlers so we don't duplicate output
|
||||
# and ensures these loggers share exactly the same handlers as base_logger.
|
||||
lg.handlers.clear()
|
||||
for h in base_logger.handlers:
|
||||
lg.addHandler(h)
|
||||
lg.setLevel(base_logger.level)
|
||||
# We've attached handlers directly to these loggers; if propagate
|
||||
# stayed True, records would bubble up to ancestor loggers and could be
|
||||
# emitted twice (here and on the parent/root). Setting False isolates them.
|
||||
lg.propagate = False
|
||||
|
||||
# Optionally capture warnings
|
||||
if capture_warnings:
|
||||
logging.captureWarnings(True)
|
||||
|
||||
|
||||
def setup_module(
|
||||
level: int | str = "INFO",
|
||||
*,
|
||||
name: str = "agentlightning",
|
||||
console: bool = True,
|
||||
color: bool | Dict[str, Any] = True,
|
||||
propagate: bool = False,
|
||||
disable_existing_loggers: bool = False,
|
||||
) -> logging.Logger:
|
||||
"""Initializes and returns the base logger for `agentlightning`.
|
||||
|
||||
This function constructs and applies a `dictConfig` configuration for the
|
||||
logger hierarchy rooted at `name`. It supports either rich console
|
||||
formatting (via `RichHandler`) or plain text formatting, based on the
|
||||
`color` argument.
|
||||
|
||||
Unlike [`setup_logging`][agentlightning.setup_logging], this function configures only a single logger namespace
|
||||
and does not attach extra handlers or submodule levels. It is primarily used
|
||||
internally by [`setup_logging`][agentlightning.setup_logging] but is also suitable for direct integration in
|
||||
custom logging workflows.
|
||||
"""
|
||||
root_cfg: Dict[str, Any] = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": disable_existing_loggers,
|
||||
"loggers": {
|
||||
name: {
|
||||
"handlers": [],
|
||||
"level": level,
|
||||
"propagate": propagate,
|
||||
}
|
||||
},
|
||||
"handlers": {},
|
||||
"formatters": {},
|
||||
}
|
||||
|
||||
# Choose formatter / handler definition
|
||||
if color is not False and console:
|
||||
# Console must be true to display colored outputs
|
||||
if isinstance(color, dict):
|
||||
rich_handler_config = color
|
||||
else:
|
||||
rich_handler_config: Dict[str, Any] = {
|
||||
"rich_tracebacks": False,
|
||||
"markup": False,
|
||||
"show_time": True,
|
||||
"show_path": True,
|
||||
}
|
||||
|
||||
if not _has_width():
|
||||
# e.g., in a CI environment.
|
||||
rich_handler_config["console"] = Console(width=200)
|
||||
|
||||
root_cfg["handlers"]["console"] = {
|
||||
"class": "rich.logging.RichHandler",
|
||||
"level": level,
|
||||
**rich_handler_config,
|
||||
}
|
||||
# RichHandler manages its own style; keep formatter None
|
||||
else:
|
||||
fmt_name = "plain"
|
||||
root_cfg["formatters"][fmt_name] = {
|
||||
"format": DEFAULT_FORMAT,
|
||||
"datefmt": DATE_FORMAT,
|
||||
}
|
||||
|
||||
if console:
|
||||
root_cfg["handlers"]["console"] = {
|
||||
"class": "logging.StreamHandler",
|
||||
"level": level,
|
||||
"formatter": fmt_name,
|
||||
}
|
||||
|
||||
# Attach selected handlers to agentlightning
|
||||
handler_names = list(root_cfg["handlers"].keys())
|
||||
root_cfg["loggers"][name]["handlers"] = handler_names
|
||||
|
||||
# Apply dictConfig (this resets the logger handlers)
|
||||
dictConfig(root_cfg)
|
||||
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
def _has_width() -> bool:
|
||||
"""Automatically determine whether the terminal has a width."""
|
||||
return sys.stdout.isatty()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from .agent import LitAgentRunner
|
||||
from .base import BaseRunner
|
||||
from .base import Runner
|
||||
from .legacy import LegacyAgentRunner
|
||||
|
||||
__all__ = [
|
||||
"BaseRunner",
|
||||
"Runner",
|
||||
"LegacyAgentRunner",
|
||||
"LitAgentRunner",
|
||||
]
|
||||
|
||||
+182
-60
@@ -11,8 +11,22 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, List, Literal, Optional, Sequence, TypeVar, cast
|
||||
from contextlib import suppress
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Sequence,
|
||||
TypeVar,
|
||||
cast,
|
||||
)
|
||||
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
|
||||
@@ -20,7 +34,7 @@ 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 BaseTracer
|
||||
from agentlightning.tracer.base import Tracer
|
||||
from agentlightning.types import (
|
||||
AttemptedRollout,
|
||||
Hook,
|
||||
@@ -30,42 +44,60 @@ from agentlightning.types import (
|
||||
RolloutRawResult,
|
||||
Span,
|
||||
)
|
||||
from agentlightning.utils.system_snapshot import system_snapshot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agentlightning.execution.events import ExecutionEvent
|
||||
|
||||
from .base import BaseRunner
|
||||
from .base import Runner
|
||||
|
||||
T_task = TypeVar("T_task")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LitAgentRunner(BaseRunner[T_task]):
|
||||
"""Runner implementation for executing agent tasks with distributed support.
|
||||
class LitAgentRunner(Runner[T_task]):
|
||||
"""Execute [`LitAgent`][agentlightning.LitAgent] tasks with tracing support.
|
||||
|
||||
This runner manages the complete lifecycle of agent rollout execution,
|
||||
including task polling, resource management, tracing, and hooks. It supports
|
||||
both continuous iteration over tasks from the store and single-step execution.
|
||||
|
||||
Attributes:
|
||||
worker_id: The unique identifier for this worker process.
|
||||
worker_id: Identifier for the active worker process, if any.
|
||||
"""
|
||||
|
||||
def __init__(self, tracer: BaseTracer, max_rollouts: Optional[int] = None, poll_interval: float = 5.0) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
tracer: Tracer,
|
||||
max_rollouts: Optional[int] = None,
|
||||
poll_interval: float = 5.0,
|
||||
heartbeat_interval: float = 10.0,
|
||||
interval_jitter: float = 0.1,
|
||||
heartbeat_launch_mode: Literal["asyncio", "thread"] = "asyncio",
|
||||
) -> None:
|
||||
"""Initialize the agent runner.
|
||||
|
||||
Args:
|
||||
tracer: The tracer instance for recording execution traces and spans.
|
||||
max_rollouts: Maximum number of tasks to process in iter() mode. If None,
|
||||
the runner will continue indefinitely until interrupted.
|
||||
poll_interval: Time in seconds to wait between polling attempts when
|
||||
no tasks are available in the store.
|
||||
tracer: [`Tracer`][agentlightning.Tracer] used for rollout spans.
|
||||
max_rollouts: Optional cap on iterations processed by
|
||||
[`iter`][agentlightning.LitAgentRunner.iter].
|
||||
poll_interval: Seconds to wait between store polls when no work is available.
|
||||
heartbeat_interval: Seconds to wait between sending heartbeats to the store.
|
||||
interval_jitter: Jitter factor for the poll interval. The actual interval will be between
|
||||
poll_interval - interval_jitter and poll_interval + interval_jitter.
|
||||
This is to avoid the overload caused by the synchronization of the runners.
|
||||
heartbeat_launch_mode: Launch mode for the heartbeat loop. Can be "asyncio" or "thread".
|
||||
"asyncio" is the default and recommended mode. Use "thread" if you are experiencing blocking coroutines.
|
||||
"""
|
||||
super().__init__()
|
||||
self._tracer = tracer
|
||||
self._max_rollouts = max_rollouts
|
||||
self._poll_interval = poll_interval
|
||||
self._heartbeat_interval = heartbeat_interval
|
||||
self._interval_jitter = interval_jitter
|
||||
self._heartbeat_launch_mode = heartbeat_launch_mode
|
||||
self._random_state = random.Random()
|
||||
|
||||
# Set later
|
||||
self._agent: Optional[LitAgent[T_task]] = None
|
||||
@@ -80,10 +112,9 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
initializes the tracer.
|
||||
|
||||
Args:
|
||||
agent: The LitAgent instance to be managed by this runner.
|
||||
hooks: Optional sequence of Hook objects to be called at various
|
||||
lifecycle stages (on_trace_start, on_trace_end, on_rollout_start,
|
||||
on_rollout_end).
|
||||
agent: [`LitAgent`][agentlightning.LitAgent] instance executed by the runner.
|
||||
hooks: Optional sequence of [`Hook`][agentlightning.Hook]
|
||||
callbacks invoked around tracing and rollout boundaries.
|
||||
**kwargs: Additional initialization arguments (currently unused).
|
||||
"""
|
||||
self._agent = agent
|
||||
@@ -100,13 +131,14 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
|
||||
Args:
|
||||
worker_id: Unique identifier for this worker process.
|
||||
store: The LightningStore instance for task coordination and data persistence.
|
||||
store: [`LightningStore`][agentlightning.LightningStore]
|
||||
used for task coordination and persistence.
|
||||
**kwargs: Additional worker-specific initialization arguments (currently unused).
|
||||
"""
|
||||
self._store = store
|
||||
self.worker_id = worker_id
|
||||
|
||||
self._tracer.init_worker(worker_id)
|
||||
self._tracer.init_worker(worker_id, store)
|
||||
|
||||
def teardown(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Teardown the runner and clean up all resources.
|
||||
@@ -131,7 +163,7 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
This method cleans up worker-specific resources and resets the worker ID.
|
||||
|
||||
Args:
|
||||
worker_id: The unique identifier of the worker being torn down.
|
||||
worker_id: Unique identifier of the worker being torn down.
|
||||
*args: Additional teardown arguments (currently unused).
|
||||
**kwargs: Additional teardown keyword arguments (currently unused).
|
||||
"""
|
||||
@@ -140,11 +172,11 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
self._tracer.teardown_worker(worker_id)
|
||||
|
||||
@property
|
||||
def tracer(self) -> BaseTracer:
|
||||
def tracer(self) -> Tracer:
|
||||
"""Get the tracer instance.
|
||||
|
||||
Returns:
|
||||
The BaseTracer instance used by this runner.
|
||||
The Tracer instance used by this runner.
|
||||
"""
|
||||
return self._tracer
|
||||
|
||||
@@ -155,7 +187,7 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
The LitAgent instance managed by this runner.
|
||||
|
||||
Raises:
|
||||
ValueError: If the agent has not been initialized via init().
|
||||
ValueError: If the agent has not been initialized via [`init`][agentlightning.LitAgentRunner.init].
|
||||
"""
|
||||
if self._agent is None:
|
||||
raise ValueError("Agent not initialized. Call init() first.")
|
||||
@@ -168,7 +200,7 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
The LightningStore instance for this worker.
|
||||
|
||||
Raises:
|
||||
ValueError: If the store has not been initialized via init_worker().
|
||||
ValueError: If the store has not been initialized via [`init_worker`][agentlightning.LitAgentRunner.init_worker].
|
||||
"""
|
||||
if self._store is None:
|
||||
raise ValueError("Store not initialized. Call init_worker() first.")
|
||||
@@ -254,8 +286,9 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
if isinstance(raw_result, float):
|
||||
# Preserve the existing spans before another span is emitted
|
||||
trace_spans = list(self._tracer.get_last_trace())
|
||||
# This will emit another span to the tracer
|
||||
reward_span = emit_reward(raw_result)
|
||||
# This will NOT emit another span to the tracer
|
||||
reward_span = emit_reward(raw_result, auto_export=False)
|
||||
# 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)
|
||||
|
||||
@@ -305,6 +338,75 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
|
||||
return trace_spans
|
||||
|
||||
async def _emit_heartbeat(self, store: LightningStore) -> None:
|
||||
"""Send a heartbeat tick to the store."""
|
||||
worker_id = self.get_worker_id()
|
||||
|
||||
try:
|
||||
await store.update_worker(worker_id, system_snapshot())
|
||||
except asyncio.CancelledError:
|
||||
# bypass the exception
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("%s Unable to update worker heartbeat.", self._log_prefix())
|
||||
|
||||
def _start_heartbeat_loop(self, store: LightningStore) -> Optional[Callable[[], Awaitable[None]]]:
|
||||
"""Start a background heartbeat loop and return an async stopper."""
|
||||
|
||||
if self._heartbeat_interval <= 0:
|
||||
return None
|
||||
|
||||
if self.worker_id is None:
|
||||
logger.warning("%s Cannot start heartbeat loop without worker_id.", self._log_prefix())
|
||||
return None
|
||||
|
||||
if self._heartbeat_launch_mode == "asyncio":
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
async def heartbeat_loop() -> None:
|
||||
while not stop_event.is_set():
|
||||
await self._emit_heartbeat(store)
|
||||
with suppress(asyncio.TimeoutError):
|
||||
interval = self._heartbeat_interval + self._random_state.uniform(
|
||||
-self._interval_jitter, self._interval_jitter
|
||||
)
|
||||
interval = max(interval, 0.01)
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=interval)
|
||||
|
||||
task = asyncio.create_task(heartbeat_loop(), name=f"{self.get_worker_id()}-heartbeat")
|
||||
|
||||
async def stop() -> None:
|
||||
stop_event.set()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
return stop
|
||||
|
||||
if self._heartbeat_launch_mode == "thread":
|
||||
stop_evt = threading.Event()
|
||||
|
||||
def thread_worker() -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
while not stop_evt.is_set():
|
||||
loop.run_until_complete(self._emit_heartbeat(store))
|
||||
interval = self._heartbeat_interval + self._random_state.uniform(
|
||||
-self._interval_jitter, self._interval_jitter
|
||||
)
|
||||
interval = max(interval, 0.01)
|
||||
stop_evt.wait(interval)
|
||||
|
||||
thread = threading.Thread(target=thread_worker, name=f"{self.get_worker_id()}-heartbeat", daemon=True)
|
||||
thread.start()
|
||||
|
||||
async def stop() -> None:
|
||||
stop_evt.set()
|
||||
await asyncio.to_thread(thread.join)
|
||||
|
||||
return stop
|
||||
|
||||
raise ValueError(f"Unsupported heartbeat launch mode: {self._heartbeat_launch_mode}")
|
||||
|
||||
async def _sleep_until_next_poll(self, event: Optional[ExecutionEvent] = None) -> None:
|
||||
"""Sleep until the next poll interval, with optional event-based interruption.
|
||||
|
||||
@@ -312,14 +414,16 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
and return early if the event is set.
|
||||
|
||||
Args:
|
||||
event: Optional ExecutionEvent object that can be used to interrupt the sleep.
|
||||
event: Optional [`ExecutionEvent`][agentlightning.ExecutionEvent] object that can be used to interrupt the sleep.
|
||||
If set during the sleep period, the method returns immediately.
|
||||
"""
|
||||
interval = self._poll_interval + self._random_state.uniform(-self._interval_jitter, self._interval_jitter)
|
||||
interval = max(interval, 0.01)
|
||||
if event is None:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
await asyncio.sleep(interval)
|
||||
return
|
||||
current_time = time.time()
|
||||
next_time = current_time + self._poll_interval
|
||||
next_time = current_time + interval
|
||||
while time.time() < next_time:
|
||||
await asyncio.sleep(0.1)
|
||||
if event.is_set():
|
||||
@@ -364,8 +468,8 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
await self._trigger_hooks(hook_type="on_rollout_start", agent=agent, runner=self, rollout=next_rollout)
|
||||
|
||||
start_time = time.time()
|
||||
with self._tracer.trace_context(
|
||||
name=rollout_id, store=store, rollout_id=rollout_id, attempt_id=next_rollout.attempt.attempt_id
|
||||
async with self._tracer.trace_context(
|
||||
name=rollout_id, rollout_id=rollout_id, attempt_id=next_rollout.attempt.attempt_id
|
||||
):
|
||||
await self._trigger_hooks(
|
||||
hook_type="on_trace_start", agent=agent, runner=self, tracer=self._tracer, rollout=next_rollout
|
||||
@@ -435,6 +539,7 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
"""Run the runner, continuously iterating over tasks in the store.
|
||||
|
||||
This method polls the store for new rollouts and executes them until:
|
||||
|
||||
- The event is set (if provided)
|
||||
- The max_rollouts limit is reached (if configured)
|
||||
- No more tasks are available
|
||||
@@ -450,39 +555,49 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
logger.info(f"{self._log_prefix()} Started async rollouts (max: {self._max_rollouts or 'unlimited'}).")
|
||||
store = self.get_store()
|
||||
|
||||
while not (event is not None and event.is_set()) and (
|
||||
self._max_rollouts is None or num_tasks_processed < self._max_rollouts
|
||||
):
|
||||
# Retrieve the next rollout
|
||||
next_rollout: Optional[Rollout] = None
|
||||
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()
|
||||
stop_heartbeat = self._start_heartbeat_loop(store)
|
||||
|
||||
try:
|
||||
while not (event is not None and event.is_set()) and (
|
||||
self._max_rollouts is None or num_tasks_processed < self._max_rollouts
|
||||
):
|
||||
# Retrieve the next rollout
|
||||
next_rollout: Optional[Rollout] = None
|
||||
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())
|
||||
if next_rollout is None:
|
||||
logger.debug(
|
||||
f"{self._log_prefix()} No rollout to poll. Waiting for {self._poll_interval} seconds."
|
||||
)
|
||||
await self._sleep_until_next_poll(event)
|
||||
else:
|
||||
break
|
||||
|
||||
if next_rollout is None:
|
||||
logger.debug(f"{self._log_prefix()} No rollout to poll. Waiting for {self._poll_interval} seconds.")
|
||||
await self._sleep_until_next_poll(event)
|
||||
else:
|
||||
break
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
# Execute the step
|
||||
await self._step_impl(next_rollout)
|
||||
|
||||
num_tasks_processed += 1
|
||||
if num_tasks_processed % 10 == 0 or num_tasks_processed == 1:
|
||||
logger.info(f"{self._log_prefix()} Progress: {num_tasks_processed}/{self._max_rollouts or 'unlimited'}")
|
||||
num_tasks_processed += 1
|
||||
if num_tasks_processed % 10 == 0 or num_tasks_processed == 1:
|
||||
logger.info(
|
||||
f"{self._log_prefix()} Progress: {num_tasks_processed}/{self._max_rollouts or 'unlimited'}"
|
||||
)
|
||||
finally:
|
||||
if stop_heartbeat is not None:
|
||||
await stop_heartbeat()
|
||||
|
||||
logger.info(f"{self._log_prefix()} Finished async rollouts. Processed {num_tasks_processed} tasks.")
|
||||
|
||||
@@ -497,7 +612,8 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
"""Execute a single task directly, bypassing the task queue.
|
||||
|
||||
This method creates a new rollout for the given input and executes it
|
||||
immediately. Unlike iter(), exceptions are propagated to the caller.
|
||||
immediately. Unlike [`iter()`][agentlightning.LitAgentRunner.iter],
|
||||
exceptions are propagated to the caller.
|
||||
|
||||
Args:
|
||||
input: The task input to be processed by the agent.
|
||||
@@ -525,6 +641,12 @@ class LitAgentRunner(BaseRunner[T_task]):
|
||||
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(),
|
||||
)
|
||||
rollout_id = await self._step_impl(attempted_rollout, raise_on_exception=True)
|
||||
|
||||
completed_rollout = await store.get_rollout_by_id(rollout_id)
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Base runner interface for executing agent tasks.
|
||||
|
||||
This module defines the abstract base class for all runner implementations
|
||||
in the agent-lightning framework. Runners are responsible for managing the
|
||||
execution lifecycle of agents and coordinating with the store.
|
||||
"""
|
||||
"""Abstract runner interface for executing agent tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,90 +22,75 @@ T_task = TypeVar("T_task")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseRunner(ParallelWorkerBase, Generic[T_task]):
|
||||
"""Base class for all runners.
|
||||
class Runner(ParallelWorkerBase, Generic[T_task]):
|
||||
"""Abstract base class for long-running agent executors.
|
||||
|
||||
This abstract base class defines the interface that all runner implementations
|
||||
must follow. Runners are responsible for executing agent tasks, managing the
|
||||
execution lifecycle, and coordinating with the store.
|
||||
Runner implementations coordinate [`LitAgent`][agentlightning.LitAgent]
|
||||
instances, acquire work from a [`LightningStore`][agentlightning.LightningStore],
|
||||
and emit [`Rollout`][agentlightning.Rollout] objects. Subclasses decide how
|
||||
to schedule work (polling, streaming, etc.) while this base class provides a
|
||||
minimal lifecycle contract.
|
||||
"""
|
||||
|
||||
def init(self, agent: LitAgent[T_task], **kwargs: Any) -> None:
|
||||
"""Initialize the runner with the agent.
|
||||
"""Prepare the runner to execute tasks for `agent`.
|
||||
|
||||
This method is called once during setup to configure the runner with
|
||||
the agent it will execute.
|
||||
This method is called only once during the setup for all workers, not for each worker.
|
||||
|
||||
Args:
|
||||
agent: The LitAgent instance to be managed by this runner.
|
||||
**kwargs: Additional initialization arguments specific to the runner implementation.
|
||||
agent: Agent instance providing task-specific logic.
|
||||
**kwargs: Optional runner-specific configuration.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by subclasses.
|
||||
NotImplementedError: Subclasses must supply the initialization
|
||||
routine.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def init_worker(self, worker_id: int, store: LightningStore, **kwargs: Any) -> None:
|
||||
"""Initialize the runner for each worker with worker_id and store.
|
||||
"""Configure worker-local state before processing tasks.
|
||||
|
||||
This method is called once per worker process in a distributed setup.
|
||||
It provides the worker with its unique ID and the store instance for
|
||||
task coordination.
|
||||
This method is called for **each** worker during the setup.
|
||||
|
||||
Args:
|
||||
worker_id: Unique identifier for this worker process.
|
||||
store: The LightningStore instance for task coordination and data persistence.
|
||||
**kwargs: Additional worker-specific initialization arguments.
|
||||
worker_id: Unique identifier for this worker process or thread.
|
||||
store: Shared [`LightningStore`][agentlightning.LightningStore]
|
||||
backing task coordination.
|
||||
**kwargs: Optional worker-specific configuration.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by subclasses.
|
||||
NotImplementedError: Subclasses must prepare per-worker resources.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def run(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Undefined method - use iter() or step() instead.
|
||||
"""Deprecated synchronous entry point.
|
||||
|
||||
This method is intentionally not implemented as the execution behavior
|
||||
should be defined through iter() for continuous execution or step()
|
||||
for single-task execution.
|
||||
|
||||
Args:
|
||||
*args: Unused positional arguments.
|
||||
**kwargs: Unused keyword arguments.
|
||||
Use [`iter()`][agentlightning.Runner.iter] or [`step()`][agentlightning.Runner.step] instead.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Always raised to indicate this method should not be used.
|
||||
RuntimeError: Always raised to direct callers to
|
||||
[iter()][agentlightning.Runner.iter] or
|
||||
[step()][agentlightning.Runner.step].
|
||||
"""
|
||||
raise RuntimeError("The behavior of run() of Runner is undefined. Use iter() or step() instead.")
|
||||
|
||||
def teardown(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Clean up runner resources and reset state.
|
||||
|
||||
This method is called once during shutdown to clean up any resources
|
||||
allocated during initialization and reset the runner state.
|
||||
|
||||
Args:
|
||||
*args: Additional teardown arguments.
|
||||
**kwargs: Additional teardown keyword arguments.
|
||||
"""Release resources acquired during [`init()`][agentlightning.Runner.init].
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by subclasses.
|
||||
NotImplementedError: Subclasses must implement the shutdown routine.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def teardown_worker(self, worker_id: int, *args: Any, **kwargs: Any) -> None:
|
||||
"""Clean up worker-specific resources.
|
||||
|
||||
This method is called once per worker during shutdown to clean up
|
||||
any resources specific to that worker.
|
||||
"""Release per-worker resources allocated by [`init_worker()`][agentlightning.Runner.init_worker].
|
||||
|
||||
Args:
|
||||
worker_id: The unique identifier of the worker being torn down.
|
||||
*args: Additional teardown arguments.
|
||||
**kwargs: Additional teardown keyword arguments.
|
||||
worker_id: Identifier of the worker being torn down.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by subclasses.
|
||||
NotImplementedError: Subclasses must implement the shutdown routine.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -122,18 +102,21 @@ class BaseRunner(ParallelWorkerBase, Generic[T_task]):
|
||||
store: LightningStore,
|
||||
hooks: Optional[Sequence[Hook]] = None,
|
||||
worker_id: Optional[int] = None,
|
||||
) -> Iterator[BaseRunner[T_task]]:
|
||||
"""Context manager for quickly init and teardown the runner,
|
||||
so that you can debug the runner without a trainer environment.
|
||||
) -> Iterator[Runner[T_task]]:
|
||||
"""Initialize and tear down a runner within a simple context manager.
|
||||
|
||||
The helper is primarily intended for debugging runner implementations
|
||||
outside of a full [`Trainer`][agentlightning.Trainer] stack.
|
||||
|
||||
Args:
|
||||
agent: The LitAgent instance to be managed by this runner.
|
||||
It should be the same agent that is to be run within the context.
|
||||
store: The LightningStore instance for task coordination and data persistence.
|
||||
If you don't have one, you can easily create one with `InMemoryLightningStore()`.
|
||||
hooks: Optional sequence of Hook instances to be used by the runner.
|
||||
Only some runners support hooks.
|
||||
worker_id: Optional worker ID to be used by the runner.
|
||||
agent: Agent executed by this runner.
|
||||
store: Backing [`LightningStore`][agentlightning.LightningStore].
|
||||
If you don't have one, you can easily create one with
|
||||
[`InMemoryLightningStore`][agentlightning.InMemoryLightningStore].
|
||||
hooks: Optional sequence of hooks recognised by the runner.
|
||||
Not all runners support hooks.
|
||||
worker_id: Override the worker identifier used during setup. Defaults
|
||||
to `0`.
|
||||
"""
|
||||
_initialized: bool = False
|
||||
_worker_initialized: bool = False
|
||||
@@ -163,12 +146,11 @@ class BaseRunner(ParallelWorkerBase, Generic[T_task]):
|
||||
them until interrupted by the event or when no more tasks are available.
|
||||
|
||||
Args:
|
||||
event: Optional ExecutionEvent object that can be used to signal the runner
|
||||
to stop gracefully. When set, the runner should finish its current
|
||||
task and exit the iteration loop.
|
||||
event: Cooperative stop signal. When set, the runner should complete
|
||||
the current unit of work and exit the loop.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by subclasses.
|
||||
NotImplementedError: Subclasses provide the iteration behavior.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -186,18 +168,15 @@ class BaseRunner(ParallelWorkerBase, Generic[T_task]):
|
||||
directly, bypassing the store's task queue.
|
||||
|
||||
Args:
|
||||
input: The task input to be processed by the agent.
|
||||
resources: Optional named resources to be used for this specific task.
|
||||
If not provided, the latest resources from the store will be used.
|
||||
mode: Optional rollout mode (e.g., "train", "test"). If not provided,
|
||||
the default mode will be used.
|
||||
event: Optional ExecutionEvent object to signal interruption. When set, the
|
||||
runner may abort the current execution.
|
||||
input: Task payload consumed by the agent.
|
||||
resources: Optional named resources scoped to this invocation.
|
||||
mode: Optional rollout mode such as `"train"` or `"eval"`.
|
||||
event: Cooperative stop signal for long-running tasks.
|
||||
|
||||
Returns:
|
||||
The completed rollout.
|
||||
Completed rollout produced by the agent.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by subclasses.
|
||||
NotImplementedError: Subclasses provide the execution behavior.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -11,10 +11,10 @@ from agentlightning.adapter import TracerTraceToTriplet
|
||||
from agentlightning.client import AgentLightningClient
|
||||
from agentlightning.litagent import LitAgent
|
||||
from agentlightning.litagent.litagent import is_v0_1_rollout_api
|
||||
from agentlightning.tracer.base import BaseTracer
|
||||
from agentlightning.tracer.base import Tracer
|
||||
from agentlightning.types import RolloutLegacy, RolloutRawResultLegacy, Triplet
|
||||
|
||||
from .base import BaseRunner
|
||||
from .base import Runner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,7 +23,7 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
class LegacyAgentRunner(BaseRunner[Any]):
|
||||
class LegacyAgentRunner(Runner[Any]):
|
||||
"""Manages the agent's execution loop and integrates with AgentOps.
|
||||
|
||||
This class orchestrates the interaction between the agent (`LitAgent`) and
|
||||
@@ -43,7 +43,7 @@ class LegacyAgentRunner(BaseRunner[Any]):
|
||||
self,
|
||||
agent: LitAgent[Any],
|
||||
client: AgentLightningClient,
|
||||
tracer: BaseTracer,
|
||||
tracer: Tracer,
|
||||
triplet_exporter: TracerTraceToTriplet,
|
||||
worker_id: Optional[int] = None,
|
||||
max_tasks: Optional[int] = None,
|
||||
@@ -58,7 +58,7 @@ class LegacyAgentRunner(BaseRunner[Any]):
|
||||
self.worker_id = worker_id
|
||||
self.max_tasks = max_tasks
|
||||
|
||||
# These methods are overridden by BaseRunner, getting them back to old behavior.
|
||||
# These methods are overridden by Runner, getting them back to old behavior.
|
||||
def init(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
@@ -180,7 +180,7 @@ class LegacyAgentRunner(BaseRunner[Any]):
|
||||
except Exception:
|
||||
logger.exception(f"{self._log_prefix(rollout_id)} Exception during on_rollout_start hook.")
|
||||
|
||||
with self.tracer.trace_context(name=f"rollout_{rollout_id}"):
|
||||
with self.tracer._trace_context_sync(name=f"rollout_{rollout_id}"): # pyright: ignore[reportPrivateUsage]
|
||||
start_time = time.time()
|
||||
rollout_method = self.agent.training_rollout if task.mode == "train" else self.agent.validation_rollout
|
||||
# Pass the task input, not the whole task object
|
||||
@@ -257,7 +257,7 @@ class LegacyAgentRunner(BaseRunner[Any]):
|
||||
except Exception:
|
||||
logger.exception(f"{self._log_prefix(rollout_id)} Exception during on_rollout_start hook.")
|
||||
|
||||
with self.tracer.trace_context(name=f"rollout_{rollout_id}"):
|
||||
async with self.tracer.trace_context(name=f"rollout_{rollout_id}"):
|
||||
start_time = time.time()
|
||||
rollout_method = (
|
||||
self.agent.training_rollout_async if task.mode == "train" else self.agent.validation_rollout_async
|
||||
|
||||
+106
-67
@@ -1,6 +1,11 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Legacy server for the Agent Lightning framework. Deprecated in favor of agentlightning.store."""
|
||||
"""Legacy HTTP server compatible with the original Agent Lightning protocol.
|
||||
|
||||
The implementation in this module predates the modern store-powered runtime and
|
||||
is kept for backwards compatibility with older deployments. New applications
|
||||
should migrate to the store architecture where possible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -29,9 +34,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServerDataStore:
|
||||
"""
|
||||
A centralized, thread-safe, async, in-memory data store for the server's state.
|
||||
This holds the task queue, versioned resources, and completed rollouts.
|
||||
"""Async-safe container for in-memory server state.
|
||||
|
||||
The store tracks queued tasks, claimed tasks, uploaded rollouts, and the
|
||||
currently published resources. All interactions are guarded by asyncio locks
|
||||
so that the FastAPI handlers can safely run in parallel.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
[`ServerDataStore`][agentlightning.server.ServerDataStore] is part of
|
||||
the legacy client/server stack. Use [`LightningStore`][agentlightning.LightningStore] instead.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -54,8 +65,18 @@ class ServerDataStore:
|
||||
resources_id: str | None = None,
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Adds a new task to the queue with specific metadata and returns its unique ID.
|
||||
"""Enqueue a new task and return the generated rollout identifier.
|
||||
|
||||
Args:
|
||||
sample: Payload that describes the task input.
|
||||
mode: Phase in which the sample should be executed (`"train"`, `"val"`, or
|
||||
`"test"`).
|
||||
resources_id: Identifier of a resource bundle that the executor should
|
||||
load before running the task.
|
||||
metadata: Optional metadata forwarded to the executor.
|
||||
|
||||
Returns:
|
||||
Unique rollout identifier assigned to the task.
|
||||
"""
|
||||
rollout_id = f"rollout-{uuid.uuid4()}"
|
||||
task = Task(
|
||||
@@ -72,9 +93,11 @@ class ServerDataStore:
|
||||
return rollout_id
|
||||
|
||||
async def get_next_task(self) -> Optional[Task]:
|
||||
"""
|
||||
Retrieves the next task from the queue without blocking.
|
||||
Returns None if the queue is empty.
|
||||
"""Retrieve the next task from the queue without blocking.
|
||||
|
||||
Returns:
|
||||
Next [`Task`][agentlightning.Task] ready to execute, or ``None``
|
||||
when the queue is empty.
|
||||
"""
|
||||
try:
|
||||
async with self._results_lock:
|
||||
@@ -95,8 +118,10 @@ class ServerDataStore:
|
||||
return None
|
||||
|
||||
async def update_resources(self, update: ResourcesUpdate):
|
||||
"""
|
||||
Safely stores a new version of named resources and sets it as the latest.
|
||||
"""Persist a new resource bundle and mark it as the latest version.
|
||||
|
||||
Args:
|
||||
update: Resource payload received from a client.
|
||||
"""
|
||||
# TODO: evict old resources if necessary.
|
||||
async with self._resources_lock:
|
||||
@@ -105,26 +130,38 @@ class ServerDataStore:
|
||||
logger.info(f"Resources updated. New version '{update.resources_id}' is now latest.")
|
||||
|
||||
async def get_resources_by_id(self, resources_id: str) -> Optional[ResourcesUpdate]:
|
||||
"""
|
||||
Safely retrieves a specific version of named resources by its ID.
|
||||
"""Retrieve a specific resource bundle by identifier.
|
||||
|
||||
Args:
|
||||
resources_id: Identifier that was previously published to the store.
|
||||
|
||||
Returns:
|
||||
Matching [`ResourcesUpdate`][agentlightning.ResourcesUpdate]
|
||||
instance, or ``None`` when the identifier is unknown.
|
||||
"""
|
||||
async with self._resources_lock:
|
||||
resources = self._resource_versions.get(resources_id)
|
||||
if resources:
|
||||
return ResourcesUpdate(resources_id=resources_id, resources=resources)
|
||||
return ResourcesUpdate(
|
||||
resources_id=resources_id,
|
||||
resources=resources,
|
||||
create_time=time.time(),
|
||||
update_time=time.time(),
|
||||
version=1,
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_latest_resources(self) -> Optional[ResourcesUpdate]:
|
||||
"""
|
||||
Safely retrieves the latest version of named resources.
|
||||
"""
|
||||
"""Return the most recent resource bundle, if one exists."""
|
||||
if self._latest_resources_id:
|
||||
return await self.get_resources_by_id(self._latest_resources_id)
|
||||
return None
|
||||
|
||||
async def store_rollout(self, rollout: RolloutLegacy):
|
||||
"""
|
||||
Safely stores a completed rollout from a client.
|
||||
"""Persist a completed rollout for later inspection.
|
||||
|
||||
Args:
|
||||
rollout: Rollout returned by a client.
|
||||
"""
|
||||
async with self._results_lock:
|
||||
self._processing_tasks.pop(rollout.rollout_id, None)
|
||||
@@ -132,27 +169,31 @@ class ServerDataStore:
|
||||
logger.info(f"Rollout received and stored: {rollout.rollout_id}")
|
||||
|
||||
async def retrieve_rollout(self, rollout_id: str) -> Optional[RolloutLegacy]:
|
||||
"""
|
||||
Safely retrieves a single rollout by its ID, removing it from the store.
|
||||
"""Retrieve and remove a stored rollout by identifier.
|
||||
|
||||
Args:
|
||||
rollout_id: Identifier of the rollout to fetch.
|
||||
|
||||
Returns:
|
||||
Stored [`RolloutLegacy`][agentlightning.RolloutLegacy], or ``None``
|
||||
when the identifier is unknown.
|
||||
"""
|
||||
async with self._results_lock:
|
||||
return self._completed_rollouts.pop(rollout_id, None)
|
||||
|
||||
async def retrieve_completed_rollouts(self) -> List[RolloutLegacy]:
|
||||
"""
|
||||
Retrieves all completed rollouts and clears the store.
|
||||
"""
|
||||
"""Return all completed rollouts and clear the internal buffer."""
|
||||
async with self._results_lock:
|
||||
rollouts = list(self._completed_rollouts.values())
|
||||
self._completed_rollouts.clear()
|
||||
return rollouts
|
||||
|
||||
def get_processing_tasks(self) -> Dict[str, Task]:
|
||||
"""Returns a copy of currently processing tasks for timeout checking."""
|
||||
"""Return a copy of currently processing tasks for timeout checking."""
|
||||
return self._processing_tasks.copy()
|
||||
|
||||
async def requeue_task(self, task: Task):
|
||||
"""Requeues a task that has timed out and removes it from processing."""
|
||||
"""Requeue a task that timed out while being processed."""
|
||||
logger.warning(f"Requeuing task {task.rollout_id} after timeout (attempt {task.num_claims})")
|
||||
async with self._results_lock:
|
||||
# Remove from processing tasks
|
||||
@@ -161,21 +202,26 @@ class ServerDataStore:
|
||||
|
||||
|
||||
class AgentLightningServer:
|
||||
"""
|
||||
The main SDK class for developers to control the Agent Lightning Server.
|
||||
"""High-level controller for the legacy Agent Lightning FastAPI server.
|
||||
|
||||
This class manages the server lifecycle, task queueing, resources updates,
|
||||
and retrieval of results, providing a simple interface for the optimization logic.
|
||||
The controller orchestrates server start-up, task queueing, resource updates,
|
||||
and retrieval of client rollouts. It is primarily used by existing systems that
|
||||
still rely on the HTTP-based workflow.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
[`AgentLightningServer`][agentlightning.server.AgentLightningServer] is part of
|
||||
the legacy client/server stack. Prefer the store-based runtime for new
|
||||
integrations.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 8000, task_timeout_seconds: float = 300.0):
|
||||
"""
|
||||
Initializes the server controller.
|
||||
"""Initialize the controller.
|
||||
|
||||
Args:
|
||||
host: The host to bind the server to.
|
||||
port: The port to bind the server to.
|
||||
task_timeout_seconds: Time in seconds after which a claimed task is considered stale and requeued.
|
||||
host: Hostname or IP address to bind the HTTP server to.
|
||||
port: TCP port exposed by the server.
|
||||
task_timeout_seconds: Seconds before a claimed task is considered stale and
|
||||
re-queued.
|
||||
"""
|
||||
warnings.warn(
|
||||
"AgentLightningServer is deprecated. Please use LightningStoreServer instead.", DeprecationWarning
|
||||
@@ -200,9 +246,7 @@ class AgentLightningServer:
|
||||
# --- ADDED: Lifespan context manager ---
|
||||
@asynccontextmanager
|
||||
async def _lifespan(self, app: FastAPI):
|
||||
"""
|
||||
Manages server startup and shutdown. This runs inside the server's event loop.
|
||||
"""
|
||||
"""Manage server start-up and shutdown within the event loop."""
|
||||
logger.info("Server is starting up...")
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self._store = ServerDataStore() # Initialize data store here
|
||||
@@ -216,9 +260,7 @@ class AgentLightningServer:
|
||||
self.loop = None
|
||||
|
||||
async def _check_and_requeue_stale_tasks(self):
|
||||
"""
|
||||
Check for stale tasks and requeue them. Called reactively during get_next_task.
|
||||
"""
|
||||
"""Check for stale tasks and requeue them when they exceed the timeout."""
|
||||
current_time = time.time()
|
||||
# Ensure store is initialized before checking
|
||||
if not self._store:
|
||||
@@ -233,11 +275,11 @@ class AgentLightningServer:
|
||||
)
|
||||
|
||||
def _setup_routes(self):
|
||||
"""Setup FastAPI routes."""
|
||||
"""Configure the FastAPI routes that make up the legacy HTTP API."""
|
||||
|
||||
@self._app.get("/task", response_model=TaskIfAny)
|
||||
async def next_task() -> TaskIfAny: # type: ignore
|
||||
"""Endpoint for clients to poll for the next available task."""
|
||||
"""Provide the next available task to a client."""
|
||||
await self._check_and_requeue_stale_tasks()
|
||||
|
||||
if not self._store:
|
||||
@@ -253,7 +295,7 @@ class AgentLightningServer:
|
||||
|
||||
@self._app.get("/resources/latest", response_model=ResourcesUpdate)
|
||||
async def fetch_latest_resources() -> ResourcesUpdate: # type: ignore
|
||||
"""Endpoint for clients to poll for the latest available resources."""
|
||||
"""Return the most recent resource bundle published to the server."""
|
||||
if not self._store:
|
||||
raise HTTPException(status_code=503, detail="Server not fully initialized.")
|
||||
resources_update = await self._store.get_latest_resources()
|
||||
@@ -266,7 +308,7 @@ class AgentLightningServer:
|
||||
async def fetch_resources_by_id( # type: ignore
|
||||
resource_id: str = Path(..., description="The unique identifier for the resource version.")
|
||||
) -> ResourcesUpdate:
|
||||
"""Endpoint for clients to fetch a specific version of resources."""
|
||||
"""Return a specific version of resources by identifier."""
|
||||
if not self._store:
|
||||
raise HTTPException(status_code=503, detail="Server not fully initialized.")
|
||||
resources_update = await self._store.get_resources_by_id(resource_id)
|
||||
@@ -277,7 +319,7 @@ class AgentLightningServer:
|
||||
|
||||
@self._app.post("/rollout", response_model=GenericResponse)
|
||||
async def post_rollout(payload: RolloutLegacy) -> GenericResponse: # type: ignore
|
||||
"""Endpoint for clients to report a completed rollout."""
|
||||
"""Persist the rollout reported by a client."""
|
||||
if not self._store:
|
||||
raise HTTPException(status_code=503, detail="Server not fully initialized.")
|
||||
await self._store.store_rollout(payload)
|
||||
@@ -287,13 +329,13 @@ class AgentLightningServer:
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
"""Starts the FastAPI server in the background."""
|
||||
"""Start the FastAPI server in the background."""
|
||||
logger.info(f"Starting server at {self.endpoint}")
|
||||
asyncio.create_task(self._uvicorn_server.serve())
|
||||
await asyncio.sleep(1) # Allow time for server to start up.
|
||||
|
||||
async def stop(self):
|
||||
"""Gracefully stops the running FastAPI server."""
|
||||
"""Stop the FastAPI server and wait for a graceful shutdown."""
|
||||
if self._uvicorn_server.started:
|
||||
logger.info("Stopping server...")
|
||||
self._uvicorn_server.should_exit = True
|
||||
@@ -301,10 +343,7 @@ class AgentLightningServer:
|
||||
logger.info("Server stopped.")
|
||||
|
||||
async def run_forever(self):
|
||||
"""
|
||||
Runs the server indefinitely until stopped.
|
||||
This is useful when async start and stop methods do not work.
|
||||
"""
|
||||
"""Run the server indefinitely until `stop()` is invoked."""
|
||||
await self._uvicorn_server.serve()
|
||||
|
||||
async def queue_task(
|
||||
@@ -314,35 +353,37 @@ class AgentLightningServer:
|
||||
resources_id: str | None = None,
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Adds a task to the queue for a client to process.
|
||||
"""
|
||||
"""Add a task to the queue for a client to process."""
|
||||
if not self._store:
|
||||
raise RuntimeError("Store not initialized. The server may not be running.")
|
||||
return await self._store.add_task(sample, mode=mode, resources_id=resources_id, metadata=metadata)
|
||||
|
||||
async def update_resources(self, resources: NamedResources) -> str:
|
||||
"""
|
||||
Updates the resources, creating a new version and setting it as the latest.
|
||||
"""
|
||||
"""Publish a new resource bundle and return its generated identifier."""
|
||||
if not self._store:
|
||||
raise RuntimeError("Store not initialized. The server may not be running.")
|
||||
resources_id = f"res-{uuid.uuid4()}"
|
||||
update = ResourcesUpdate(resources_id=resources_id, resources=resources)
|
||||
update = ResourcesUpdate(
|
||||
resources_id=resources_id, resources=resources, create_time=time.time(), update_time=time.time(), version=1
|
||||
)
|
||||
await self._store.update_resources(update)
|
||||
return resources_id
|
||||
|
||||
async def get_completed_rollout(self, rollout_id: str) -> Optional[RolloutLegacy]:
|
||||
"""
|
||||
Retrieves a specific completed rollout by its ID.
|
||||
"""
|
||||
"""Retrieve a specific completed rollout by identifier."""
|
||||
if not self._store:
|
||||
raise RuntimeError("Store not initialized. The server may not be running.")
|
||||
return await self._store.retrieve_rollout(rollout_id)
|
||||
|
||||
async def poll_completed_rollout(self, rollout_id: str, timeout: Optional[float] = None) -> Optional[RolloutLegacy]:
|
||||
"""
|
||||
Polls for a completed rollout by its ID, waiting up to `timeout` seconds.
|
||||
"""Poll for a completed rollout until it becomes available or a timeout expires.
|
||||
|
||||
Args:
|
||||
rollout_id: Identifier of the rollout to wait for.
|
||||
timeout: Maximum number of seconds to wait. ``None`` waits indefinitely.
|
||||
|
||||
Returns:
|
||||
Retrieved rollout, or ``None`` when the timeout is reached without success.
|
||||
"""
|
||||
start_time = time.time()
|
||||
while True:
|
||||
@@ -354,9 +395,7 @@ class AgentLightningServer:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def retrieve_completed_rollouts(self) -> List[RolloutLegacy]:
|
||||
"""
|
||||
Retrieves all available completed trajectories and clears the internal store.
|
||||
"""
|
||||
"""Return every completed rollout and clear the internal buffer."""
|
||||
if not self._store:
|
||||
raise RuntimeError("Store not initialized. The server may not be running.")
|
||||
return await self._store.retrieve_completed_rollouts()
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from .base import LightningStore
|
||||
from .base import LightningStore, LightningStoreCapabilities
|
||||
from .client_server import LightningStoreClient, LightningStoreServer
|
||||
from .collection_based import CollectionBasedLightningStore
|
||||
from .memory import InMemoryLightningStore
|
||||
from .threading import LightningStoreThreaded
|
||||
|
||||
__all__ = [
|
||||
"LightningStore",
|
||||
"LightningStoreCapabilities",
|
||||
"LightningStoreClient",
|
||||
"LightningStoreServer",
|
||||
"InMemoryLightningStore",
|
||||
"CollectionBasedLightningStore",
|
||||
"LightningStoreThreaded",
|
||||
]
|
||||
|
||||
+572
-85
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional, Sequence
|
||||
from typing import Any, Dict, List, Literal, Optional, Sequence, TypedDict
|
||||
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
|
||||
@@ -17,6 +17,8 @@ from agentlightning.types import (
|
||||
RolloutStatus,
|
||||
Span,
|
||||
TaskInput,
|
||||
Worker,
|
||||
WorkerStatus,
|
||||
)
|
||||
|
||||
|
||||
@@ -52,33 +54,107 @@ UNSET = _UnsetType()
|
||||
Unset = _UnsetType # Alias for convenience
|
||||
|
||||
|
||||
class LightningStore:
|
||||
"""
|
||||
A centralized, thread-safe, async, data store for the lightning's state.
|
||||
This holds the task queue, versioned resources, and completed rollouts.
|
||||
class LightningStoreCapabilities(TypedDict, total=False):
|
||||
"""Capability of a LightningStore implementation.
|
||||
|
||||
The store has a built-in clock and it should be responsible for tracking the times.
|
||||
All the time-based operations like retry, timeout, etc. should be handled by the store.
|
||||
All keys are optional and false by default.
|
||||
"""
|
||||
|
||||
thread_safe: bool
|
||||
"""Whether the store is thread-safe."""
|
||||
async_safe: bool
|
||||
"""Whether the store is async-safe."""
|
||||
zero_copy: bool
|
||||
"""Whether the store has only one copy across all threads/processes."""
|
||||
otlp_traces: bool
|
||||
"""Whether the store supports OTLP/HTTP traces."""
|
||||
|
||||
|
||||
class LightningStore:
|
||||
"""Contract for the persistent control-plane that coordinates training rollouts.
|
||||
|
||||
A `LightningStore` mediates every interaction between algorithms and runners:
|
||||
|
||||
- **Rollout lifecycle:** accept new rollouts, queue them for execution, create attempts,
|
||||
and drive the rollout status machine (`"queuing"` → `"preparing"` → `"running"` →
|
||||
`{"succeeded","failed","cancelled"}` or `"requeuing"` when a retry is justified).
|
||||
- **Attempt tracking:** record each execution attempt, including progress heartbeats,
|
||||
retry sequencing, and terminal states such as `"timeout"` or `"unresponsive"`.
|
||||
- **Span ingest:** capture structured telemetry emitted by runners (either as native
|
||||
[`Span`][agentlightning.Span] objects or as `opentelemetry.sdk.trace.ReadableSpan`
|
||||
instances) so that algorithms can reconstruct trajectories and rewards.
|
||||
- **Resource versioning:** manage immutable snapshots of named resources
|
||||
(prompt templates, model checkpoints, proxy endpoints, …) and expose a single
|
||||
"latest" snapshot that runners can fetch just after claiming work.
|
||||
|
||||
Implementations must provide thread-safe/async-safe semantics: each coroutine should
|
||||
appear atomic to callers even when multiple algorithms or runners call the API concurrently.
|
||||
Unless stated otherwise, missing identifiers should result in a `ValueError`.
|
||||
"""
|
||||
|
||||
@property
|
||||
def capabilities(self) -> LightningStoreCapabilities:
|
||||
"""Return the capabilities of the store."""
|
||||
return LightningStoreCapabilities(
|
||||
thread_safe=False,
|
||||
async_safe=False,
|
||||
zero_copy=False,
|
||||
otlp_traces=False,
|
||||
)
|
||||
|
||||
def otlp_traces_endpoint(self) -> str:
|
||||
"""Return the OTLP/HTTP traces endpoint of the store.
|
||||
|
||||
The traces can have rollout ID and attempt ID (and optionally sequence ID)
|
||||
saved in the "resource" of the spans.
|
||||
The store, if it supports OTLP, should be able to receive the traces and save them
|
||||
via [`add_span`][agentlightning.LightningStore.add_span] or
|
||||
[`add_otel_span`][agentlightning.LightningStore.add_otel_span].
|
||||
|
||||
The endpoint should be compatible with [OTLP HTTP protocol](https://opentelemetry.io/docs/specs/otlp/).
|
||||
It's not necessarily compatible with OTLP gRPC protocol.
|
||||
|
||||
The returned endpoint will usually ends with `/v1/traces`.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def start_rollout(
|
||||
self,
|
||||
input: TaskInput,
|
||||
mode: Literal["train", "val", "test"] | None = None,
|
||||
resources_id: str | None = None,
|
||||
config: RolloutConfig | None = None,
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> AttemptedRollout:
|
||||
"""
|
||||
Add one incomplete rollout to the store, and get an attempt created for it.
|
||||
This will immediately sets the rollout to a preparing state, and should be
|
||||
used by whoever is going to execute the rollout.
|
||||
"""Register a rollout and immediately create its first attempt.
|
||||
|
||||
Return a special rollout with attempt object. Do not update it directly.
|
||||
!!! note
|
||||
Use [`enqueue_rollout()`][agentlightning.LightningStore.enqueue_rollout] when the
|
||||
caller only wants to submit work for later scheduling.
|
||||
|
||||
But if the rollout fails or timeouts, it's still possible that the watchdog
|
||||
sends it back to the queue for retry.
|
||||
The rollout must be persisted with `status="preparing"` and an initial attempt
|
||||
with `sequence_id == 1` so the caller can begin execution without visiting the
|
||||
public queue. Implementations are expected to:
|
||||
|
||||
To enqueue a rollout to the task queue, use `enqueue_rollout` instead.
|
||||
1. Generate a unique `rollout_id` and `attempt_id`.
|
||||
2. Record `start_time` for both rollout and attempt based on the current clock.
|
||||
3. Copy `config` and `metadata` so later mutations do not leak shared references.
|
||||
4. Resolve `resources_id` to the latest resource snapshot when `None` is supplied.
|
||||
|
||||
Args:
|
||||
input: Arbitrary task payload supplied by an algorithm.
|
||||
mode: Optional semantic mode for downstream analytics (`"train"`, `"val"`, `"test"`).
|
||||
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.
|
||||
|
||||
Returns:
|
||||
The fully-populated [`AttemptedRollout`][agentlightning.AttemptedRollout] including
|
||||
the just-created attempt.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must provide durable storage for the rollout.
|
||||
ValueError: Implementations should raise when `resources_id` does not exist.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -87,34 +163,101 @@ class LightningStore:
|
||||
input: TaskInput,
|
||||
mode: Literal["train", "val", "test"] | None = None,
|
||||
resources_id: str | None = None,
|
||||
config: RolloutConfig | None = None,
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> Rollout:
|
||||
"""
|
||||
Adds a new task to the queue with specific metadata and
|
||||
returns the rollout object with its unique ID.
|
||||
"""Persist a rollout in `queuing` state so runners can claim it later.
|
||||
|
||||
!!! note
|
||||
Different from [`start_rollout()`][agentlightning.LightningStore.start_rollout],
|
||||
this method is called when the caller only wants to submit work for later scheduling.
|
||||
|
||||
Implementations must generate a unique `rollout_id`, stamp `start_time` with
|
||||
the current time, default `config` to a fresh [`RolloutConfig`][agentlightning.RolloutConfig],
|
||||
and insert the rollout at the tail of the scheduling queue. No attempt is created yet.
|
||||
|
||||
Args:
|
||||
input: Arbitrary task payload supplied by an algorithm.
|
||||
mode: Optional semantic mode indicator (`"train"`, `"val"`, `"test"`).
|
||||
resources_id: Resource snapshot used when a runner eventually executes the rollout.
|
||||
config: Fine-grained retry/timeout parameters to persist with the rollout.
|
||||
metadata: Free-form metadata stored verbatim with the rollout record.
|
||||
|
||||
Returns:
|
||||
The stored [`Rollout`][agentlightning.Rollout] in `queuing` status.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must persist the rollout.
|
||||
ValueError: Implementations should raise when `resources_id` does not exist.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def dequeue_rollout(self) -> Optional[AttemptedRollout]:
|
||||
"""
|
||||
Retrieves the next task from the queue without blocking.
|
||||
Returns None if the queue is empty.
|
||||
async def dequeue_rollout(self, worker_id: Optional[str] = None) -> Optional[AttemptedRollout]:
|
||||
"""Claim the oldest queued rollout and transition it to `preparing`.
|
||||
|
||||
Will set the rollout status to preparing.
|
||||
This function do not block.
|
||||
|
||||
Retrieval must be FIFO across rollouts that remain in `queuing` or `requeuing`
|
||||
state. When a rollout is claimed, implementations must:
|
||||
|
||||
* Transition its status to `"preparing"`.
|
||||
* Create a new attempt with `status="preparing"` and `sequence_id` equal to
|
||||
the number of attempts already registered for the rollout plus one.
|
||||
* Return an [`AttemptedRollout`][agentlightning.AttemptedRollout] snapshot so the
|
||||
runner knows both rollout metadata and the attempt identifier.
|
||||
* Optionally refresh the caller's [`Worker`][agentlightning.Worker] telemetry
|
||||
(e.g., `last_dequeue_time`) when `worker_id` is provided.
|
||||
|
||||
Returns:
|
||||
The next attempt to execute, or `None` when no eligible rollouts are queued.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement queue retrieval.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
|
||||
"""
|
||||
Create a new attempt for a given rollout ID and return the attempt details.
|
||||
"""Create a manual retry attempt for an existing rollout.
|
||||
|
||||
This is typically invoked by runners that wish to retry outside of the
|
||||
normal queue flow (for example in an online RL setup).
|
||||
Implementations must validate that the rollout exists, allocate a fresh `attempt_id`,
|
||||
increment the `sequence_id` monotonically, stamp the new attempt with `status="preparing"`,
|
||||
and return an up-to-date [`AttemptedRollout`][agentlightning.AttemptedRollout].
|
||||
|
||||
Args:
|
||||
rollout_id: Unique identifier of the rollout receiving a new attempt.
|
||||
|
||||
Returns:
|
||||
The rollout paired with its newly-created attempt.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement attempt creation.
|
||||
ValueError: Implementations must raise when `rollout_id` is unknown.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def add_span(self, span: Span) -> Span:
|
||||
"""
|
||||
Add a span to the store.
|
||||
"""Persist a pre-constructed span emitted during rollout execution.
|
||||
|
||||
This method is responsible for updating the rollout/attempt status to "running" if needed.
|
||||
The provided [`Span`][agentlightning.Span] must already contain the `rollout_id`,
|
||||
`attempt_id`, and `sequence_id`. Implementations must:
|
||||
|
||||
* Verify that both rollout and attempt exist.
|
||||
* Ensure span ordering remains strictly increasing per attempt (rejecting or keeping duplicates).
|
||||
* Treat the span arrival as a heartbeat: update the attempt's `last_heartbeat_time`
|
||||
and transition both attempt and rollout to `"running"` if they were still
|
||||
`"preparing"` or `"requeuing"`.
|
||||
|
||||
Args:
|
||||
span: Fully populated span to persist.
|
||||
|
||||
Returns:
|
||||
The stored span record (implementations may return a copy).
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement span persistence.
|
||||
ValueError: Implementations must raise when the referenced rollout or attempt is missing.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -125,88 +268,345 @@ class LightningStore:
|
||||
readable_span: ReadableSpan,
|
||||
sequence_id: int | None = None,
|
||||
) -> Span:
|
||||
"""
|
||||
Add an opentelemetry span to the store.
|
||||
"""Convert and persist an OpenTelemetry span for a particular attempt.
|
||||
|
||||
If sequence_id is not provided, it will be fetched from `get_next_span_sequence_id` and assigned automatically.
|
||||
Implementations must transform the `readable_span` into a [`Span`][agentlightning.Span]
|
||||
(typically via [`Span.from_opentelemetry()`][agentlightning.Span.from_opentelemetry]),
|
||||
assign a strictly increasing `sequence_id` when one is not provided, and persist it
|
||||
using the same semantics as [`add_span()`][agentlightning.LightningStore.add_span].
|
||||
|
||||
Args:
|
||||
rollout_id: Identifier of the rollout that produced the span.
|
||||
attempt_id: Attempt identifier the span belongs to.
|
||||
readable_span: OpenTelemetry span in SDK form.
|
||||
sequence_id: Optional explicit ordering hint. When omitted, call
|
||||
[`get_next_span_sequence_id()`][agentlightning.LightningStore.get_next_span_sequence_id]
|
||||
automatically.
|
||||
|
||||
Returns:
|
||||
The stored span record.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement span persistence.
|
||||
ValueError: Implementations must raise when the rollout or attempt is unknown.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def query_rollouts(
|
||||
self, *, status: Optional[Sequence[RolloutStatus]] = None, rollout_ids: Optional[Sequence[str]] = None
|
||||
) -> List[Rollout]:
|
||||
"""
|
||||
Query and retrieve rollouts filtered by their status.
|
||||
If no status is provided, returns all rollouts.
|
||||
self,
|
||||
*,
|
||||
status_in: Optional[Sequence[RolloutStatus]] = None,
|
||||
rollout_id_in: Optional[Sequence[str]] = None,
|
||||
rollout_id_contains: Optional[str] = None,
|
||||
filter_logic: Literal["and", "or"] = "and",
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Literal["asc", "desc"] = "asc",
|
||||
limit: int = -1,
|
||||
offset: int = 0,
|
||||
# Deprecated fields
|
||||
status: Optional[Sequence[RolloutStatus]] = None,
|
||||
rollout_ids: Optional[Sequence[str]] = None,
|
||||
) -> Sequence[Rollout]:
|
||||
"""Retrieve rollouts filtered by status and/or explicit identifiers.
|
||||
|
||||
This interface supports structured filtering, sorting, and pagination so
|
||||
callers can build simple dashboards without copying data out of the
|
||||
store. The legacy parameters `status` and `rollout_ids` remain valid and
|
||||
are treated as aliases for `status_in` and `rollout_id_in`
|
||||
respectively—when both the new and deprecated parameters are supplied
|
||||
the new parameters take precedence.
|
||||
|
||||
Args:
|
||||
status_in: Optional whitelist of [`RolloutStatus`][agentlightning.RolloutStatus] values.
|
||||
rollout_id_in: Optional whitelist of rollout identifiers to include.
|
||||
rollout_id_contains: Optional substring match for rollout identifiers.
|
||||
filter_logic: Logical operator to combine filters.
|
||||
sort_by: Optional field to sort by. Must reference a numeric or string
|
||||
field on [`Rollout`][agentlightning.Rollout].
|
||||
sort_order: Direction to sort when `sort_by` is provided.
|
||||
limit: Maximum number of rows to return. Use `-1` for "no limit".
|
||||
offset: Number of rows to skip before returning results.
|
||||
status: Deprecated field. Use `status_in` instead.
|
||||
rollout_ids: Deprecated field. Use `rollout_id_in` instead.
|
||||
|
||||
Returns:
|
||||
A sequence of matching rollouts (or [`AttemptedRollout`][agentlightning.AttemptedRollout]
|
||||
when attempts exist). Ordering is deterministic when `sort_by` is set.
|
||||
The return value is not guaranteed to be a list.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement the query.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def query_attempts(self, rollout_id: str) -> List[Attempt]:
|
||||
"""
|
||||
Query and retrieve all attempts associated with a specific rollout ID.
|
||||
Returns an empty list if no attempts are found.
|
||||
async def query_attempts(
|
||||
self,
|
||||
rollout_id: str,
|
||||
*,
|
||||
sort_by: Optional[str] = "sequence_id",
|
||||
sort_order: Literal["asc", "desc"] = "asc",
|
||||
limit: int = -1,
|
||||
offset: int = 0,
|
||||
) -> Sequence[Attempt]:
|
||||
"""Return every attempt ever created for `rollout_id` in ascending sequence order.
|
||||
|
||||
The parameters allow callers to re-order or paginate the attempts so that
|
||||
large retry histories can be streamed lazily.
|
||||
|
||||
Args:
|
||||
rollout_id: Identifier of the rollout being inspected.
|
||||
sort_by: Field to sort by. Must be a numeric or string field of
|
||||
[`Attempt`][agentlightning.Attempt]. Defaults to `sequence_id` (oldest first).
|
||||
sort_order: Order to sort by.
|
||||
limit: Limit on the number of results. `-1` for unlimited.
|
||||
offset: Offset into the results.
|
||||
|
||||
Returns:
|
||||
Sequence of Attempts. Returns an empty sequence when none exist.
|
||||
The return value is not guaranteed to be a list.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement the query.
|
||||
ValueError: Implementations must raise when the rollout does not exist.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get_rollout_by_id(self, rollout_id: str) -> Optional[Rollout]:
|
||||
"""
|
||||
Safely retrieves a specific rollout by its ID.
|
||||
"""Fetch a rollout by identifier without mutating its state.
|
||||
|
||||
Args:
|
||||
rollout_id: Identifier to retrieve.
|
||||
|
||||
Returns:
|
||||
The rollout when found, otherwise `None`.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement retrieval.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get_latest_attempt(self, rollout_id: str) -> Optional[Attempt]:
|
||||
"""Fetch the attempt with the highest `sequence_id` for `rollout_id`.
|
||||
|
||||
Args:
|
||||
rollout_id: Identifier to inspect.
|
||||
|
||||
Returns:
|
||||
The most recent attempt or `None` when no attempts exist yet.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement retrieval.
|
||||
ValueError: Implementations must raise when the rollout does not exist.
|
||||
"""
|
||||
Safely retrieves the latest attempt for a given rollout ID.
|
||||
raise NotImplementedError()
|
||||
|
||||
async def query_resources(
|
||||
self,
|
||||
*,
|
||||
resources_id: Optional[str] = None,
|
||||
resources_id_contains: Optional[str] = None,
|
||||
# Filter logic is not supported here because I can't see why it's needed.
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Literal["asc", "desc"] = "asc",
|
||||
limit: int = -1,
|
||||
offset: int = 0,
|
||||
) -> Sequence[ResourcesUpdate]:
|
||||
"""List every stored resource snapshot in insertion order.
|
||||
|
||||
Supports lightweight filtering, sorting, and pagination for embedding in
|
||||
dashboards.
|
||||
|
||||
Args:
|
||||
resources_id: Optional identifier of the resources to include.
|
||||
resources_id_contains: Optional substring match for resources identifiers.
|
||||
sort_by: Optional field to sort by (must be numeric or string on
|
||||
[`ResourcesUpdate`][agentlightning.ResourcesUpdate]).
|
||||
sort_order: Order to sort by.
|
||||
limit: Limit on the number of results. `-1` for unlimited.
|
||||
offset: Offset into the results.
|
||||
|
||||
Returns:
|
||||
[`ResourcesUpdate`][agentlightning.ResourcesUpdate] objects.
|
||||
By default, resources are sorted in a deterministic but undefined order.
|
||||
The return value is not guaranteed to be a list.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement retrieval.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get_resources_by_id(self, resources_id: str) -> Optional[ResourcesUpdate]:
|
||||
"""
|
||||
Safely retrieves a specific version of named resources by its ID.
|
||||
"""Return a specific named resource snapshot by identifier.
|
||||
|
||||
Args:
|
||||
resources_id: Identifier of the snapshot.
|
||||
|
||||
Returns:
|
||||
The stored [`ResourcesUpdate`][agentlightning.ResourcesUpdate], or `None` when missing.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement retrieval.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get_latest_resources(self) -> Optional[ResourcesUpdate]:
|
||||
"""
|
||||
Safely retrieves the latest version of named resources.
|
||||
"""Fetch the latest resource snapshot marked as the global default.
|
||||
|
||||
Returns:
|
||||
The current latest [`ResourcesUpdate`][agentlightning.ResourcesUpdate], or `None` when
|
||||
no resources have been registered yet.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement retrieval.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get_next_span_sequence_id(self, rollout_id: str, attempt_id: str) -> int:
|
||||
"""
|
||||
Get the next span sequence ID for a given rollout and attempt.
|
||||
This should be used to assign a unique sequence ID to each span within an attempt.
|
||||
"""Allocate the next strictly increasing sequence number used to order spans.
|
||||
|
||||
Recommend getting the ID before the operation even begins to avoid racing conditions.
|
||||
Implementations must retain counters so repeated calls return `1, 2, ...` without
|
||||
gaps unless spans were explicitly inserted with a custom `sequence_id`. The
|
||||
counter may be scoped per rollout or per attempt, but the sequence must be
|
||||
strictly increasing for spans emitted by the specified attempt so traces remain
|
||||
totally ordered.
|
||||
|
||||
See [Distributed Tracing][distributed-tracing] for detailed motivations.
|
||||
|
||||
Args:
|
||||
rollout_id: Identifier of the rollout emitting spans.
|
||||
attempt_id: Attempt identifier for the upcoming span.
|
||||
|
||||
Returns:
|
||||
The next integer sequence identifier, unique within the attempt.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must provide the allocator.
|
||||
ValueError: Implementations must raise when the rollout or attempt does not exist.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def wait_for_rollouts(self, *, rollout_ids: List[str], timeout: Optional[float] = None) -> List[Rollout]:
|
||||
"""
|
||||
Wait for specified rollouts to complete with a timeout.
|
||||
Returns the completed rollouts, potentially incomplete if timeout is reached.
|
||||
"""Block until the targeted rollouts reach a terminal status or the timeout expires.
|
||||
|
||||
TODO: Add support for waiting for 20 new rollouts, or wait until 80% of the pending ids are completed.
|
||||
Terminal statuses are `"succeeded"`, `"failed"`, and `"cancelled"`. When the timeout
|
||||
elapses, implementations should return the subset of rollouts that are already terminal
|
||||
and omit the rest.
|
||||
|
||||
!!! warning
|
||||
It's dangerous and might be event-loop blocking to call this function
|
||||
with a long timeout. It's a good idea to poll for the method to check
|
||||
if new completed rollouts can coming. Be careful in implementing the sleep logic
|
||||
to avoid busy-waiting.
|
||||
|
||||
Args:
|
||||
rollout_ids: Identifiers of rollouts to watch.
|
||||
timeout: Maximum time in seconds to wait. `None` waits indefinitely.
|
||||
|
||||
Returns:
|
||||
Rollouts that finished before the deadline, in arbitrary order.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement waiting semantics.
|
||||
ValueError: Implementations must raise when a rollout identifier is unknown.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def query_spans(self, rollout_id: str, attempt_id: str | Literal["latest"] | None = None) -> List[Span]:
|
||||
"""
|
||||
Query and retrieve all spans associated with a specific rollout ID.
|
||||
Returns an empty list if no spans are found.
|
||||
async def query_spans(
|
||||
self,
|
||||
rollout_id: str,
|
||||
attempt_id: str | Literal["latest"] | None = None,
|
||||
*,
|
||||
# Filtering
|
||||
trace_id: Optional[str] = None,
|
||||
trace_id_contains: Optional[str] = None,
|
||||
span_id: Optional[str] = None,
|
||||
span_id_contains: Optional[str] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
parent_id_contains: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
name_contains: Optional[str] = None,
|
||||
filter_logic: Literal["and", "or"] = "and",
|
||||
# Pagination
|
||||
limit: int = -1,
|
||||
offset: int = 0,
|
||||
# Sorting
|
||||
sort_by: Optional[str] = "sequence_id",
|
||||
sort_order: Literal["asc", "desc"] = "asc",
|
||||
) -> Sequence[Span]:
|
||||
"""Return the stored spans for a rollout, optionally scoped to one attempt.
|
||||
|
||||
Supports a handful of filters that cover the most common debugging
|
||||
scenarios (matching `trace_id`/`span_id`/`parent_id` or substring
|
||||
matches on the span name). `attempt_id="latest"` acts as a convenience
|
||||
that resolves the most recent attempt before evaluating filters. When
|
||||
`attempt_id=None`, spans across every attempt are eligible. By default
|
||||
results are sorted by `sequence_id` (oldest first). Implementations may
|
||||
raise a `RuntimeError` when spans were evicted or expired.
|
||||
|
||||
Args:
|
||||
rollout_id: Identifier of the rollout being inspected.
|
||||
attempt_id: Attempt identifier to filter by. Pass `"latest"` to retrieve only the
|
||||
most recent attempt, or `None` to return all spans across attempts.
|
||||
trace_id: Optional trace ID to filter by.
|
||||
trace_id_contains: Optional substring match for trace IDs.
|
||||
span_id: Optional span ID to filter by.
|
||||
span_id_contains: Optional substring match for span IDs.
|
||||
parent_id: Optional parent span ID to filter by.
|
||||
parent_id_contains: Optional substring match for parent span IDs.
|
||||
name: Optional span name to filter by.
|
||||
name_contains: Optional substring match for span names.
|
||||
filter_logic: Logical operator to combine the optional filters above.
|
||||
The `rollout_id` argument is always applied with AND semantics.
|
||||
limit: Limit on the number of results. `-1` for unlimited.
|
||||
offset: Offset into the results.
|
||||
sort_by: Field to sort by. Must be a numeric or string field of
|
||||
[`Span`][agentlightning.Span].
|
||||
sort_order: Order to sort by.
|
||||
|
||||
Returns:
|
||||
An ordered list of spans (possibly empty).
|
||||
The return value is not guaranteed to be a list.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement the query.
|
||||
ValueError: Implementations must raise when the rollout or attempt is unknown.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def add_resources(self, resources: NamedResources) -> ResourcesUpdate:
|
||||
"""
|
||||
Safely stores a new version of named resources and sets it as the latest.
|
||||
Not implemented by many stores yet.
|
||||
"""Persist a new immutable snapshot of named resources and mark it as latest.
|
||||
|
||||
Implementations must assign a fresh `resources_id` and ensure subsequent calls to
|
||||
[`get_latest_resources()`][agentlightning.LightningStore.get_latest_resources] return the
|
||||
snapshot produced here.
|
||||
|
||||
Args:
|
||||
resources: Mapping of resource names to their serialized payloads.
|
||||
|
||||
Returns:
|
||||
The stored [`ResourcesUpdate`][agentlightning.ResourcesUpdate] including its generated id.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement resource persistence.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def update_resources(self, resources_id: str, resources: NamedResources) -> ResourcesUpdate:
|
||||
"""
|
||||
Safely stores a new version or updates an existing version of named resources and sets it as the latest.
|
||||
"""Overwrite or extend an existing resource snapshot and mark it as latest.
|
||||
|
||||
This API is typically used by algorithms that maintain mutable resources (e.g., model
|
||||
checkpoints) under a stable identifier.
|
||||
|
||||
Args:
|
||||
resources_id: Identifier of the snapshot to replace.
|
||||
resources: Updated mapping of resource names to payloads.
|
||||
|
||||
Returns:
|
||||
The persisted [`ResourcesUpdate`][agentlightning.ResourcesUpdate].
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement resource persistence.
|
||||
ValueError: Implementations must raise when `resources_id` does not exist.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -220,22 +620,31 @@ class LightningStore:
|
||||
config: RolloutConfig | Unset = UNSET,
|
||||
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
|
||||
) -> Rollout:
|
||||
"""
|
||||
Update the rollout status and related metadata.
|
||||
"""Update rollout metadata and, when provided, drive status transitions.
|
||||
|
||||
Not-listed fields here either cannot be updated, or should be auto-updated (e.g., end_time).
|
||||
Parameters default to the sentinel [`UNSET`][agentlightning.store.base.UNSET] to
|
||||
distinguish omitted fields from explicit `None` assignments. Implementations must:
|
||||
|
||||
When status is updated to a finished / problematic state, other states like task
|
||||
queues will be updated accordingly.
|
||||
* Validate the rollout exists before mutating it.
|
||||
* Replace each property when a concrete value (including `None`) is supplied.
|
||||
* When the status switches into a terminal state, set `end_time` and signal any waiters.
|
||||
* When the status re-enters a queueing state, ensure the rollout is enqueued exactly once.
|
||||
|
||||
Args:
|
||||
rollout_id: Unique identifier for the rollout to update
|
||||
input: New input data for the rollout. If set, will be updated. Can be updated to None
|
||||
mode: New mode for the rollout. If set, will be updated. Can be updated to None
|
||||
resources_id: New resources ID for the rollout. If set, will be updated. Can be updated to None
|
||||
status: New status for the rollout. If set, will be updated
|
||||
config: New config for the rollout. If set, will be updated
|
||||
metadata: Dictionary of additional metadata to update. If set, will replace the existing metadata
|
||||
rollout_id: Identifier of the rollout to update.
|
||||
input: Replacement task payload; pass `None` to explicitly clear the input.
|
||||
mode: Replacement rollout mode.
|
||||
resources_id: Replacement resources snapshot reference.
|
||||
status: Target rollout status.
|
||||
config: Replacement retry/timeout configuration.
|
||||
metadata: Replacement metadata dictionary.
|
||||
|
||||
Returns:
|
||||
The updated rollout record.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement mutation logic.
|
||||
ValueError: Implementations must raise when the rollout is unknown or the update is invalid.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -248,18 +657,96 @@ class LightningStore:
|
||||
last_heartbeat_time: float | Unset = UNSET,
|
||||
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
|
||||
) -> Attempt:
|
||||
"""
|
||||
Update a specific or latest attempt for a given rollout.
|
||||
"""Update attempt bookkeeping such as status, worker ownership, and heartbeats.
|
||||
|
||||
Update the latest attempt will NOT affect the corresponding rollout status.
|
||||
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])
|
||||
once the latest attempt transitions to a terminal state.
|
||||
|
||||
Similar to [`update_rollout()`][agentlightning.LightningStore.update_rollout],
|
||||
parameters also default to the sentinel [`UNSET`][agentlightning.store.base.UNSET].
|
||||
|
||||
If `worker_id` is present, the worker status will be updated following the rules:
|
||||
|
||||
1. If attempt status is "succeeded" or "failed", the corresponding worker status will be set to "idle".
|
||||
2. If attempt status is "unresponsive" or "timeout", the corresponding worker status will be set to "unknown".
|
||||
3. Otherwise, the worker status will be set to "busy".
|
||||
|
||||
Args:
|
||||
rollout_id: Unique identifier for the rollout
|
||||
attempt_id: Unique identifier for the attempt
|
||||
status: Status to set for the attempt, update if provided
|
||||
worker_id: Worker identifier, update if provided
|
||||
last_heartbeat_time: Timestamp of the last heartbeat from the worker
|
||||
metadata: Dictionary of additional metadata to update, will replace the existing metadata
|
||||
rollout_id: Identifier of the rollout whose attempt will be updated.
|
||||
attempt_id: Attempt identifier or `"latest"` as a convenience.
|
||||
status: Replacement attempt status. Terminal statuses must set `end_time`.
|
||||
worker_id: Identifier for the worker currently processing the attempt.
|
||||
last_heartbeat_time: Wall-clock timestamp (seconds) of the latest heartbeat/span.
|
||||
metadata: Replacement metadata dictionary.
|
||||
|
||||
Returns:
|
||||
The updated attempt record.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement mutation logic.
|
||||
ValueError: Implementations must raise when the rollout or attempt is unknown.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def query_workers(
|
||||
self,
|
||||
*,
|
||||
status_in: Optional[Sequence[WorkerStatus]] = None,
|
||||
worker_id_contains: Optional[str] = None,
|
||||
filter_logic: Literal["and", "or"] = "and",
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Literal["asc", "desc"] = "asc",
|
||||
limit: int = -1,
|
||||
offset: int = 0,
|
||||
) -> Sequence[Worker]:
|
||||
"""Query all workers in the system.
|
||||
|
||||
Args:
|
||||
status_in: Optional whitelist of [`WorkerStatus`][agentlightning.WorkerStatus] values.
|
||||
worker_id_contains: Optional substring match for worker identifiers.
|
||||
filter_logic: Logical operator to combine the optional filters above.
|
||||
sort_by: Field to sort by. Must be a numeric or string field of [`Worker`][agentlightning.Worker].
|
||||
sort_order: Order to sort by.
|
||||
limit: Limit on the number of results. `-1` for unlimited.
|
||||
offset: Offset into the results.
|
||||
|
||||
Returns:
|
||||
Sequence of Workers. Returns an empty sequence when none exist.
|
||||
The return value is not guaranteed to be a list.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get_worker_by_id(self, worker_id: str) -> Optional[Worker]:
|
||||
"""Retrieve a single worker by identifier.
|
||||
|
||||
Args:
|
||||
worker_id: Identifier of the worker.
|
||||
|
||||
Returns:
|
||||
The worker record if it exists, otherwise `None`.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Subclasses must implement lookup semantics.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def update_worker(
|
||||
self,
|
||||
worker_id: str,
|
||||
heartbeat_stats: Dict[str, Any] | Unset = UNSET,
|
||||
) -> Worker:
|
||||
"""Record a heartbeat for `worker_id` and refresh telemetry.
|
||||
|
||||
Implementations must treat this API as heartbeat-only: it should snapshot
|
||||
the latest stats when provided, stamp `last_heartbeat_time` with the
|
||||
current wall clock, and rely on other store mutations (`dequeue_rollout`,
|
||||
`update_attempt`, etc.) to drive the worker's busy/idle status,
|
||||
assignment, and activity timestamps.
|
||||
|
||||
Args:
|
||||
worker_id: Identifier of the worker to update.
|
||||
heartbeat_stats: Replacement worker heartbeat statistics (non-null when provided).
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
+1242
-341
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from .base import Collection, FilterOptions, KeyValue, LightningCollections, PaginatedResult, Queue, SortOptions
|
||||
from .memory import DequeBasedQueue, DictBasedKeyValue, InMemoryLightningCollections, ListBasedCollection
|
||||
|
||||
__all__ = [
|
||||
"Collection",
|
||||
"Queue",
|
||||
"KeyValue",
|
||||
"FilterOptions",
|
||||
"SortOptions",
|
||||
"PaginatedResult",
|
||||
"LightningCollections",
|
||||
"ListBasedCollection",
|
||||
"DequeBasedQueue",
|
||||
"DictBasedKeyValue",
|
||||
"InMemoryLightningCollections",
|
||||
]
|
||||
@@ -0,0 +1,356 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncContextManager,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
Generic,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
MutableMapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
cast,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Self
|
||||
|
||||
from agentlightning.types import (
|
||||
Attempt,
|
||||
FilterField,
|
||||
FilterOptions,
|
||||
PaginatedResult,
|
||||
ResourcesUpdate,
|
||||
Rollout,
|
||||
SortOptions,
|
||||
Span,
|
||||
Worker,
|
||||
)
|
||||
|
||||
T = TypeVar("T") # Recommended to be a BaseModel
|
||||
K = TypeVar("K")
|
||||
V = TypeVar("V")
|
||||
|
||||
|
||||
class Collection(Generic[T]):
|
||||
"""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."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}[{self.item_type().__name__}]>"
|
||||
|
||||
def item_type(self) -> Type[T]:
|
||||
"""Get the type of the items in the collection."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def size(self) -> int:
|
||||
"""Get the number of items in the collection."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def query(
|
||||
self,
|
||||
filter: Optional[FilterOptions] = None,
|
||||
sort: Optional[SortOptions] = None,
|
||||
limit: int = -1,
|
||||
offset: int = 0,
|
||||
) -> PaginatedResult[T]:
|
||||
"""Query the collection with the given filters, sort order, and pagination.
|
||||
|
||||
Args:
|
||||
filter:
|
||||
The filters to apply to the collection. See [`FilterOptions`][agentlightning.FilterOptions].
|
||||
|
||||
sort:
|
||||
The options for sorting the collection. See [`SortOptions`][agentlightning.SortOptions].
|
||||
The field must exist in the model. If field might contain null values, in which case the behavior is undefined
|
||||
(i.e., depending on the implementation).
|
||||
|
||||
limit:
|
||||
Max number of items to return. Use -1 for "no limit".
|
||||
|
||||
offset:
|
||||
Number of items to skip from the start of the *matching* items.
|
||||
|
||||
Returns:
|
||||
PaginatedResult with items, limit, offset, and total matched items.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get(
|
||||
self,
|
||||
filter: Optional[FilterOptions] = None,
|
||||
sort: Optional[SortOptions] = None,
|
||||
) -> Optional[T]:
|
||||
"""Get the first item that matches the given filters.
|
||||
|
||||
Args:
|
||||
filter: The filters to apply to the collection.
|
||||
See [`FilterOptions`][agentlightning.store.collection.FilterOptions].
|
||||
sort: Sort options. See [`SortOptions`][agentlightning.store.collection.SortOptions].
|
||||
|
||||
Returns:
|
||||
The first item that matches the given filters, or None if no item matches.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def insert(self, items: Sequence[T]) -> None:
|
||||
"""Add the given items to the collection.
|
||||
|
||||
Raises:
|
||||
ValueError: If an item with the same primary key already exists.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def update(self, items: Sequence[T]) -> None:
|
||||
"""Update the given items in the collection.
|
||||
|
||||
Raises:
|
||||
ValueError: If an item with the primary keys does not exist.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def upsert(self, items: Sequence[T]) -> None:
|
||||
"""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.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def delete(self, items: Sequence[T]) -> None:
|
||||
"""Delete the given items from the collection.
|
||||
|
||||
Args:
|
||||
items: The items to delete from the collection.
|
||||
|
||||
Raises:
|
||||
ValueError: If the items with the primary keys to be deleted do not exist.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class Queue(Generic[T]):
|
||||
"""Behaves like a deque. Supporting appending items to the end and popping items from the front."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}[{self.item_type().__name__}]>"
|
||||
|
||||
def item_type(self) -> Type[T]:
|
||||
"""Get the type of the items in the queue."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def has(self, item: T) -> bool:
|
||||
"""Check if the given item is in the queue."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def enqueue(self, items: Sequence[T]) -> Sequence[T]:
|
||||
"""Append the given items to the end of the queue.
|
||||
|
||||
Args:
|
||||
items: The items to append to the end of the queue.
|
||||
|
||||
Returns:
|
||||
The items that were appended to the end of the queue.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def dequeue(self, limit: int = 1) -> Sequence[T]:
|
||||
"""Pop the given number of items from the front of the queue.
|
||||
|
||||
Args:
|
||||
limit: The number of items to pop from the front of the queue.
|
||||
|
||||
Returns:
|
||||
The items that were popped from the front of the queue.
|
||||
If there are less than `limit` items in the queue, the remaining items will be returned.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def peek(self, limit: int = 1) -> Sequence[T]:
|
||||
"""Peek the given number of items from the front of the queue.
|
||||
|
||||
Args:
|
||||
limit: The number of items to peek from the front of the queue.
|
||||
|
||||
Returns:
|
||||
The items that were peeked from the front of the queue.
|
||||
If there are less than `limit` items in the queue, the remaining items will be returned.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def size(self) -> int:
|
||||
"""Get the number of items in the queue."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class KeyValue(Generic[K, V]):
|
||||
"""Behaves like a dictionary. Supporting addition, updating, and deletion of items."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}>"
|
||||
|
||||
async def has(self, key: K) -> bool:
|
||||
"""Check if the given key is in the dictionary."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get(self, key: K, default: V | None = None) -> V | None:
|
||||
"""Get the value for the given key, or the default value if the key is not found."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def set(self, key: K, value: V) -> None:
|
||||
"""Set the value for the given key."""
|
||||
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()
|
||||
|
||||
async def size(self) -> int:
|
||||
"""Get the number of items in the dictionary."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class LightningCollections:
|
||||
"""Collections of rollouts, attempts, spans, resources, and workers.
|
||||
|
||||
[LightningStore][agentlightning.LightningStore] implementations can use this as a storage base
|
||||
to implement the store API.
|
||||
"""
|
||||
|
||||
@property
|
||||
def rollouts(self) -> Collection[Rollout]:
|
||||
"""Collections of rollouts."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def attempts(self) -> Collection[Attempt]:
|
||||
"""Collections of attempts."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def spans(self) -> Collection[Span]:
|
||||
"""Collections of spans."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def resources(self) -> Collection[ResourcesUpdate]:
|
||||
"""Collections of resources."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def workers(self) -> Collection[Worker]:
|
||||
"""Collections of workers."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def rollout_queue(self) -> Queue[str]:
|
||||
"""Queue of rollouts (tasks)."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def span_sequence_ids(self) -> KeyValue[str, int]:
|
||||
"""Dictionary (counter) of span sequence IDs."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def atomic(self, *args: Any, **kwargs: Any) -> AsyncContextManager[Self]:
|
||||
"""Perform a atomic operation on the collections.
|
||||
|
||||
Subclass may use args and kwargs to support multiple levels of atomicity.
|
||||
|
||||
Args:
|
||||
*args: Arguments to pass to the operation.
|
||||
**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:
|
||||
return await callback(collections)
|
||||
|
||||
|
||||
FilterMap = Mapping[str, FilterField]
|
||||
|
||||
|
||||
def merge_must_filters(target: MutableMapping[str, FilterField], definition: Any) -> None:
|
||||
"""Normalize a `_must` filter group into the provided mapping.
|
||||
|
||||
Mainly for validation purposes.
|
||||
"""
|
||||
if definition is None:
|
||||
return
|
||||
|
||||
entries: List[Mapping[str, FilterField]] = []
|
||||
if isinstance(definition, Mapping):
|
||||
entries.append(cast(Mapping[str, FilterField], definition))
|
||||
elif isinstance(definition, Sequence) and not isinstance(definition, (str, bytes)):
|
||||
for entry in definition: # type: ignore
|
||||
if not isinstance(entry, Mapping):
|
||||
raise TypeError("Each `_must` entry must be a mapping of field names to operators")
|
||||
entries.append(cast(Mapping[str, FilterField], entry))
|
||||
else:
|
||||
raise TypeError("`_must` filters must be provided as a mapping or sequence of mappings")
|
||||
|
||||
for entry in entries:
|
||||
for field_name, ops in entry.items():
|
||||
existing = target.get(field_name, {})
|
||||
merged_ops: Dict[str, Any] = dict(existing)
|
||||
for op_name, expected in ops.items():
|
||||
if op_name in merged_ops:
|
||||
raise ValueError(f"Duplicate operator '{op_name}' for field '{field_name}' in must filters")
|
||||
merged_ops[op_name] = expected
|
||||
target[field_name] = cast(FilterField, merged_ops)
|
||||
|
||||
|
||||
def normalize_filter_options(
|
||||
filter_options: Optional[FilterOptions],
|
||||
) -> Tuple[Optional[FilterMap], Optional[FilterMap], Literal["and", "or"]]:
|
||||
"""Convert FilterOptions to the internal structure and resolve aggregate logic."""
|
||||
if not filter_options:
|
||||
return None, None, "and"
|
||||
|
||||
aggregate = cast(Literal["and", "or"], filter_options.get("_aggregate", "and"))
|
||||
if aggregate not in ("and", "or"):
|
||||
raise ValueError(f"Unsupported filter aggregate '{aggregate}'")
|
||||
|
||||
# Extract normalized filters and must filters from the filter options.
|
||||
normalized: Dict[str, FilterField] = {}
|
||||
must_filters: Dict[str, FilterField] = {}
|
||||
for field_name, ops in filter_options.items():
|
||||
if field_name == "_aggregate":
|
||||
continue
|
||||
if field_name == "_must":
|
||||
merge_must_filters(must_filters, ops)
|
||||
continue
|
||||
normalized[field_name] = cast(FilterField, dict(ops)) # type: ignore
|
||||
|
||||
return (normalized or None, must_filters or None, aggregate)
|
||||
|
||||
|
||||
def resolve_sort_options(sort: Optional[SortOptions]) -> Tuple[Optional[str], Literal["asc", "desc"]]:
|
||||
"""Extract sort field/order from the caller-provided SortOptions."""
|
||||
if not sort:
|
||||
return None, "asc"
|
||||
|
||||
sort_name = sort.get("name")
|
||||
if not sort_name:
|
||||
raise ValueError("Sort options must include a 'name' field")
|
||||
|
||||
sort_order = sort.get("order", "asc")
|
||||
if sort_order not in ("asc", "desc"):
|
||||
raise ValueError(f"Unsupported sort order '{sort_order}'")
|
||||
|
||||
return sort_name, sort_order
|
||||
@@ -0,0 +1,744 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import weakref
|
||||
from collections import deque
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import (
|
||||
Any,
|
||||
Deque,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
MutableMapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
|
||||
from agentlightning.types import (
|
||||
Attempt,
|
||||
FilterField,
|
||||
FilterOptions,
|
||||
PaginatedResult,
|
||||
ResourcesUpdate,
|
||||
Rollout,
|
||||
SortOptions,
|
||||
Span,
|
||||
Worker,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
Collection,
|
||||
FilterMap,
|
||||
KeyValue,
|
||||
LightningCollections,
|
||||
Queue,
|
||||
normalize_filter_options,
|
||||
resolve_sort_options,
|
||||
)
|
||||
|
||||
T = TypeVar("T") # Recommended to be a BaseModel, not a dict
|
||||
K = TypeVar("K")
|
||||
V = TypeVar("V")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Nested structure type:
|
||||
# dict[pk1] -> dict[pk2] -> ... -> item
|
||||
ListBasedCollectionItemType = Union[
|
||||
Dict[Any, "ListBasedCollectionItemType[T]"], # intermediate node
|
||||
Dict[Any, T], # leaf node dictionary
|
||||
]
|
||||
|
||||
MutationMode = Literal["insert", "update", "upsert", "delete"]
|
||||
|
||||
|
||||
def _item_matches_filters(
|
||||
item: object,
|
||||
filters: Optional[FilterMap],
|
||||
filter_logic: Literal["and", "or"],
|
||||
must_filters: Optional[FilterMap] = None,
|
||||
) -> bool:
|
||||
"""Check whether an item matches the provided filter definition.
|
||||
|
||||
Filter format:
|
||||
|
||||
```json
|
||||
{
|
||||
"_aggregate": "or",
|
||||
"field_name": {
|
||||
"exact": <value>,
|
||||
"within": <iterable_of_allowed_values>,
|
||||
"contains": <substring_or_element>,
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Operators within the same field are stored in a unified pool and combined using
|
||||
a universal logical operator.
|
||||
"""
|
||||
if must_filters and not _item_matches_filters(item, must_filters, "and"):
|
||||
return False
|
||||
|
||||
if not filters:
|
||||
return True
|
||||
|
||||
all_conditions_match: List[bool] = []
|
||||
|
||||
for field_name, ops in filters.items():
|
||||
item_value = getattr(item, field_name, None)
|
||||
|
||||
for op_name, expected in ops.items():
|
||||
# Ignore no-op filters
|
||||
if expected is None:
|
||||
continue
|
||||
|
||||
if op_name == "exact":
|
||||
all_conditions_match.append(item_value == expected)
|
||||
|
||||
elif op_name == "within":
|
||||
try:
|
||||
all_conditions_match.append(item_value in expected) # type: ignore[arg-type]
|
||||
except TypeError:
|
||||
all_conditions_match.append(False)
|
||||
|
||||
elif op_name == "contains":
|
||||
if item_value is None:
|
||||
all_conditions_match.append(False)
|
||||
elif isinstance(item_value, str) and isinstance(expected, str):
|
||||
all_conditions_match.append(expected in item_value)
|
||||
else:
|
||||
# Fallback: treat as generic iterable containment.
|
||||
try:
|
||||
all_conditions_match.append(expected in item_value) # type: ignore[arg-type]
|
||||
except TypeError:
|
||||
all_conditions_match.append(False)
|
||||
else:
|
||||
raise ValueError(f"Unsupported filter operator '{op_name}' for field '{field_name}'")
|
||||
|
||||
return all(all_conditions_match) if filter_logic == "and" else any(all_conditions_match)
|
||||
|
||||
|
||||
def _get_sort_value(item: object, sort_by: str) -> Any:
|
||||
"""Get a sort key for the given item/field.
|
||||
|
||||
- If the field name ends with '_time', values are treated as comparable timestamps.
|
||||
- For other fields we try to infer a safe default from the Pydantic model annotation.
|
||||
"""
|
||||
value = getattr(item, sort_by, None)
|
||||
|
||||
if sort_by.endswith("_time"):
|
||||
# For *_time fields, push missing values to the end.
|
||||
return float("inf") if value is None else value
|
||||
|
||||
if value is None:
|
||||
# Introspect model field type to choose a reasonable default for None.
|
||||
model_fields = getattr(item.__class__, "model_fields", {})
|
||||
if sort_by not in model_fields:
|
||||
raise ValueError(
|
||||
f"Failed to sort items by '{sort_by}': field does not exist " f"on {item.__class__.__name__}"
|
||||
)
|
||||
|
||||
field_type_str = str(model_fields[sort_by].annotation)
|
||||
if "str" in field_type_str or "Literal" in field_type_str:
|
||||
return ""
|
||||
if "int" in field_type_str:
|
||||
return 0
|
||||
if "float" in field_type_str:
|
||||
return 0.0
|
||||
raise ValueError(f"Failed to sort items by '{sort_by}': unsupported field type {field_type_str!r}")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class ListBasedCollection(Collection[T]):
|
||||
"""In-memory implementation of Collection using a nested dict for O(1) primary-key lookup.
|
||||
|
||||
The internal structure is:
|
||||
|
||||
{
|
||||
pk1_value: {
|
||||
pk2_value: {
|
||||
...
|
||||
pkN_value: item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
where the nesting depth equals the number of primary keys.
|
||||
|
||||
Sorting behavior:
|
||||
|
||||
1. If no sort_by is provided, the items are returned in the order of insertion.
|
||||
2. If sort_by is provided, the items are sorted by the value of the sort_by field.
|
||||
3. If the sort_by field is a timestamp, the null values are treated as infinity.
|
||||
4. If the sort_by field is not a timestamp, the null values are treated as empty string
|
||||
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]):
|
||||
if not primary_keys:
|
||||
raise ValueError("primary_keys must be non-empty")
|
||||
|
||||
self._items: Dict[Any, Any] = {}
|
||||
self._size: int = 0
|
||||
if issubclass(item_type, dict):
|
||||
raise TypeError(f"Expect item to be not a dict, got {item_type.__name__}")
|
||||
self._item_type: Type[T] = item_type
|
||||
self._primary_keys: Tuple[str, ...] = tuple(primary_keys)
|
||||
|
||||
# Pre-populate the collection with the given items.
|
||||
for item in items or []:
|
||||
self._mutate_single(item, mode="insert")
|
||||
|
||||
def primary_keys(self) -> Sequence[str]:
|
||||
"""Return the primary key field names for this collection."""
|
||||
return self._primary_keys
|
||||
|
||||
def item_type(self) -> Type[T]:
|
||||
"""Return the Pydantic model type of items stored in this collection."""
|
||||
return self._item_type
|
||||
|
||||
async def size(self) -> int:
|
||||
"""Return the number of items stored in the collection."""
|
||||
return self._size
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}[{self.item_type().__name__}] ({self._size})>"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _ensure_item_type(self, item: T) -> None:
|
||||
"""Validate that the item matches the declared item_type."""
|
||||
if not isinstance(item, self._item_type):
|
||||
raise TypeError(f"Expected item of type {self._item_type.__name__}, " f"got {type(item).__name__}")
|
||||
|
||||
def _extract_primary_key_values(self, item: T) -> Tuple[Any, ...]:
|
||||
"""Extract the primary key values from an item.
|
||||
|
||||
Raises:
|
||||
ValueError: If any primary key is missing on the item.
|
||||
"""
|
||||
values: List[Any] = []
|
||||
for key in self._primary_keys:
|
||||
if not hasattr(item, key):
|
||||
raise ValueError(f"Item {item} does not have primary key field '{key}'")
|
||||
values.append(getattr(item, key))
|
||||
return tuple(values)
|
||||
|
||||
def _render_key_values(self, key_values: Sequence[Any]) -> str:
|
||||
return ", ".join(f"{name}={value!r}" for name, value in zip(self._primary_keys, key_values))
|
||||
|
||||
def _locate_node(
|
||||
self,
|
||||
key_values: Sequence[Any],
|
||||
create_missing: bool,
|
||||
) -> Tuple[MutableMapping[Any, Any], Any]:
|
||||
"""Locate the parent mapping and final key for an item path.
|
||||
|
||||
Args:
|
||||
key_values: The sequence of primary key values.
|
||||
create_missing: Whether to create intermediate dictionaries as needed.
|
||||
|
||||
Returns:
|
||||
(parent_mapping, final_key)
|
||||
|
||||
Raises:
|
||||
KeyError: If the path does not exist and create_missing is False.
|
||||
ValueError: If the internal structure is corrupted (non-dict where dict is expected).
|
||||
"""
|
||||
if not key_values:
|
||||
raise ValueError("key_values must be non-empty")
|
||||
|
||||
current: MutableMapping[Any, Any] = self._items
|
||||
for idx, value in enumerate(key_values):
|
||||
is_last = idx == len(key_values) - 1
|
||||
if is_last:
|
||||
# At the final level, current[value] is the item (or will be).
|
||||
return current, value # type: ignore
|
||||
|
||||
# Intermediate level: current[value] must be a dict.
|
||||
if value not in current:
|
||||
if not create_missing:
|
||||
raise KeyError(f"Path does not exist for given primary keys: {self._render_key_values(key_values)}")
|
||||
current[value] = {}
|
||||
next_node = current[value] # type: ignore
|
||||
if not isinstance(next_node, dict):
|
||||
raise ValueError(f"Internal structure corrupted: expected dict, got {type(next_node)!r}") # type: ignore
|
||||
current = next_node # type: ignore
|
||||
|
||||
# We should always return inside the loop.
|
||||
raise RuntimeError("Unreachable")
|
||||
|
||||
def _mutate_single(self, item: T, mode: MutationMode) -> None:
|
||||
"""Core mutation logic shared by insert, update, upsert, and delete."""
|
||||
self._ensure_item_type(item)
|
||||
key_values = self._extract_primary_key_values(item)
|
||||
|
||||
if mode in ("insert", "upsert"):
|
||||
parent, final_key = self._locate_node(key_values, create_missing=True)
|
||||
exists = final_key in parent
|
||||
|
||||
if mode == "insert":
|
||||
if exists:
|
||||
raise ValueError(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
|
||||
|
||||
elif mode in ("update", "delete"):
|
||||
# For update/delete we must not create missing paths.
|
||||
try:
|
||||
parent, final_key = self._locate_node(key_values, create_missing=False)
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"Item does not exist with primary key(s): {self._render_key_values(key_values)}"
|
||||
) from None
|
||||
|
||||
if final_key not in parent:
|
||||
raise ValueError(f"Item does not exist with primary key(s): {self._render_key_values(key_values)}")
|
||||
|
||||
if mode == "update":
|
||||
parent[final_key] = item
|
||||
else: # delete
|
||||
del parent[final_key]
|
||||
self._size -= 1
|
||||
else:
|
||||
raise ValueError(f"Unknown mutation mode: {mode}")
|
||||
|
||||
def _iter_items(
|
||||
self,
|
||||
root: Optional[Mapping[Any, Any]] = None,
|
||||
filters: Optional[FilterMap] = None,
|
||||
must_filters: Optional[FilterMap] = None,
|
||||
filter_logic: Literal["and", "or"] = "and",
|
||||
) -> Iterable[T]:
|
||||
"""Iterate over all items in the nested dictionary structure, optionally applying filters."""
|
||||
if root is None:
|
||||
root = self._items
|
||||
if not root:
|
||||
return
|
||||
stack: List[Mapping[Any, Any]] = [root]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
for value in node.values():
|
||||
# Leaf nodes contain items; intermediate nodes are dicts.
|
||||
if isinstance(value, self._item_type):
|
||||
if _item_matches_filters(value, filters, filter_logic, must_filters):
|
||||
yield value
|
||||
elif isinstance(value, dict):
|
||||
stack.append(value) # type: ignore
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Internal structure corrupted: expected dict or {self._item_type.__name__}, "
|
||||
f"got {type(value)!r}"
|
||||
)
|
||||
|
||||
def _iter_matching_items(
|
||||
self,
|
||||
filters: Optional[FilterMap],
|
||||
must_filters: Optional[FilterMap],
|
||||
filter_logic: Literal["and", "or"],
|
||||
) -> Iterable[T]:
|
||||
"""Efficiently iterate over items matching filters, using primary-key prefix when possible."""
|
||||
# Fast path: when optional filters can't form a prefix, fall back to scanning.
|
||||
if filter_logic != "and" and must_filters is None:
|
||||
return self._iter_items(filters=filters, must_filters=must_filters, filter_logic=filter_logic)
|
||||
|
||||
# Try to derive a primary-key prefix from exact filters.
|
||||
pk_values_prefix: List[Any] = []
|
||||
prefix_sources: List[FilterMap] = []
|
||||
if must_filters:
|
||||
prefix_sources.append(must_filters)
|
||||
if filter_logic == "and" and filters:
|
||||
prefix_sources.append(filters)
|
||||
|
||||
for pk in self._primary_keys:
|
||||
# combined_ops are: [{"exact": value}, {"within": [...]}, ...]
|
||||
combined_ops: List[FilterField] = []
|
||||
for source in prefix_sources:
|
||||
field_ops = source.get(pk) # type: ignore[union-attr]
|
||||
if field_ops:
|
||||
combined_ops.append(field_ops)
|
||||
if not combined_ops:
|
||||
break
|
||||
# Only allow a pure {"exact": value} constraint.
|
||||
exact_value: Any | None = None
|
||||
allow_prefix = True
|
||||
for ops in combined_ops:
|
||||
if set(ops.keys()) != {"exact"}:
|
||||
allow_prefix = False
|
||||
break
|
||||
candidate = ops.get("exact")
|
||||
if candidate is None:
|
||||
allow_prefix = False
|
||||
break
|
||||
if exact_value is not None and candidate != exact_value:
|
||||
# Contradictory exact filters mean no items can match.
|
||||
logger.warning(f"Contradictory exact filters for field '{pk}': {exact_value} != {candidate}")
|
||||
return ()
|
||||
exact_value = candidate
|
||||
|
||||
if not allow_prefix:
|
||||
break
|
||||
|
||||
value = exact_value
|
||||
if value is None:
|
||||
break
|
||||
pk_values_prefix.append(value)
|
||||
|
||||
if not pk_values_prefix:
|
||||
return self._iter_items(filters=filters, must_filters=must_filters, filter_logic=filter_logic)
|
||||
|
||||
try:
|
||||
if len(pk_values_prefix) == len(self._primary_keys):
|
||||
# All primary keys specified -> at most a single item.
|
||||
parent, final_key = self._locate_node(pk_values_prefix, create_missing=False)
|
||||
single_item = parent.get(final_key)
|
||||
if isinstance(single_item, self._item_type) and _item_matches_filters(
|
||||
single_item,
|
||||
filters,
|
||||
filter_logic,
|
||||
must_filters,
|
||||
):
|
||||
return (single_item,)
|
||||
return ()
|
||||
else:
|
||||
# Prefix of primary keys specified -> iterate only the subtree below that prefix.
|
||||
parent, final_key = self._locate_node(pk_values_prefix, create_missing=False)
|
||||
subtree = parent.get(final_key)
|
||||
if isinstance(subtree, dict):
|
||||
return self._iter_items(
|
||||
subtree, # type: ignore
|
||||
filters=filters,
|
||||
must_filters=must_filters,
|
||||
filter_logic=filter_logic,
|
||||
)
|
||||
return ()
|
||||
except KeyError:
|
||||
# No items exist for this primary-key prefix.
|
||||
return ()
|
||||
|
||||
async def query(
|
||||
self,
|
||||
filter: Optional[FilterOptions] = None,
|
||||
sort: Optional[SortOptions] = None,
|
||||
limit: int = -1,
|
||||
offset: int = 0,
|
||||
) -> PaginatedResult[T]:
|
||||
"""Query the collection with filters, sort order, and pagination.
|
||||
|
||||
Args:
|
||||
filter: Mapping of field name to operator dict along with the optional `_aggregate` logic.
|
||||
sort: Options describing which field to sort by and in which order.
|
||||
limit: Max number of items to return. Use -1 for "no limit".
|
||||
offset: Number of items to skip from the start of the *matching* items.
|
||||
"""
|
||||
filters, must_filters, filter_logic = normalize_filter_options(filter)
|
||||
sort_by, sort_order = resolve_sort_options(sort)
|
||||
items_iter: Iterable[T] = self._iter_matching_items(filters, must_filters, filter_logic)
|
||||
|
||||
# No sorting: stream through items and apply pagination on the fly.
|
||||
if not sort_by:
|
||||
matched_items: List[T] = []
|
||||
total_matched = 0
|
||||
|
||||
for item in items_iter:
|
||||
# Count every match for 'total'
|
||||
total_matched += 1
|
||||
|
||||
# Apply offset/limit window
|
||||
if total_matched <= offset:
|
||||
continue
|
||||
if limit != -1 and len(matched_items) >= limit:
|
||||
# Still need to finish iteration to get accurate total_matched.
|
||||
continue
|
||||
|
||||
matched_items.append(item)
|
||||
|
||||
return PaginatedResult(
|
||||
items=matched_items,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
total=total_matched,
|
||||
)
|
||||
|
||||
# With sorting: we must materialize all matching items to sort them.
|
||||
all_matches: List[T] = list(items_iter)
|
||||
|
||||
total_matched = len(all_matches)
|
||||
reverse = sort_order == "desc"
|
||||
all_matches.sort(key=lambda x: _get_sort_value(x, sort_by), reverse=reverse)
|
||||
|
||||
if limit == -1:
|
||||
paginated_items = all_matches[offset:]
|
||||
else:
|
||||
paginated_items = all_matches[offset : offset + limit]
|
||||
|
||||
return PaginatedResult(
|
||||
items=paginated_items,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
total=total_matched,
|
||||
)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
filter: Optional[FilterOptions] = None,
|
||||
sort: Optional[SortOptions] = None,
|
||||
) -> Optional[T]:
|
||||
"""Return the first (or best-sorted) item that matches the given filters, or None."""
|
||||
filters, must_filters, filter_logic = normalize_filter_options(filter)
|
||||
sort_by, sort_order = resolve_sort_options(sort)
|
||||
items_iter: Iterable[T] = self._iter_matching_items(filters, must_filters, filter_logic)
|
||||
|
||||
if not sort_by:
|
||||
# Just return the first matching item, if any.
|
||||
for item in items_iter:
|
||||
return item
|
||||
return None
|
||||
|
||||
# Single-pass min/max according to sort_order.
|
||||
best_item: Optional[T] = None
|
||||
best_key: Any = None
|
||||
|
||||
for item in items_iter:
|
||||
key = _get_sort_value(item, sort_by)
|
||||
if best_item is None:
|
||||
best_item = item
|
||||
best_key = key
|
||||
continue
|
||||
|
||||
if sort_order == "asc":
|
||||
if key < best_key:
|
||||
best_item, best_key = item, key
|
||||
else:
|
||||
if key > best_key:
|
||||
best_item, best_key = item, key
|
||||
|
||||
return best_item
|
||||
|
||||
async def insert(self, items: Sequence[T]) -> None:
|
||||
"""Insert the given items.
|
||||
|
||||
Raises:
|
||||
ValueError: If any item with the same primary keys already exists.
|
||||
"""
|
||||
for item in items:
|
||||
self._mutate_single(item, mode="insert")
|
||||
|
||||
async def update(self, items: Sequence[T]) -> None:
|
||||
"""Update the given items.
|
||||
|
||||
Raises:
|
||||
ValueError: If any item with the given primary keys does not exist.
|
||||
"""
|
||||
for item in items:
|
||||
self._mutate_single(item, mode="update")
|
||||
|
||||
async def upsert(self, items: Sequence[T]) -> None:
|
||||
"""Upsert the given items (insert if missing, otherwise update)."""
|
||||
for item in items:
|
||||
self._mutate_single(item, mode="upsert")
|
||||
|
||||
async def delete(self, items: Sequence[T]) -> None:
|
||||
"""Delete the given items.
|
||||
|
||||
Raises:
|
||||
ValueError: If any item with the given primary keys does not exist.
|
||||
"""
|
||||
# We use a two-phase approach to avoid partial deletion if one fails:
|
||||
# first compute key_values to validate, then perform deletions.
|
||||
for item in items:
|
||||
# _mutate_single will validate existence and update size.
|
||||
self._mutate_single(item, mode="delete")
|
||||
|
||||
|
||||
class DequeBasedQueue(Queue[T]):
|
||||
"""Queue implementation backed by collections.deque.
|
||||
|
||||
Provides O(1) amortized enqueue (append) and dequeue (popleft).
|
||||
"""
|
||||
|
||||
def __init__(self, item_type: Type[T], items: Optional[Sequence[T]] = None):
|
||||
self._items: Deque[T] = deque()
|
||||
self._item_type: Type[T] = item_type
|
||||
if items:
|
||||
self._items.extend(items)
|
||||
|
||||
def item_type(self) -> Type[T]:
|
||||
return self._item_type
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}[{self.item_type().__name__}] ({len(self._items)})>"
|
||||
|
||||
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
|
||||
|
||||
async def enqueue(self, items: Sequence[T]) -> Sequence[T]:
|
||||
for item in items:
|
||||
if not isinstance(item, self._item_type):
|
||||
raise TypeError(f"Expected item of type {self._item_type.__name__}, got {type(item).__name__}")
|
||||
self._items.append(item)
|
||||
return items
|
||||
|
||||
async def dequeue(self, limit: int = 1) -> Sequence[T]:
|
||||
if limit <= 0:
|
||||
return []
|
||||
out: List[T] = []
|
||||
for _ in range(min(limit, len(self._items))):
|
||||
out.append(self._items.popleft())
|
||||
return out
|
||||
|
||||
async def peek(self, limit: int = 1) -> Sequence[T]:
|
||||
if limit <= 0:
|
||||
return []
|
||||
result: List[T] = []
|
||||
count = min(limit, len(self._items))
|
||||
for idx, item in enumerate(self._items):
|
||||
if idx >= count:
|
||||
break
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
async def size(self) -> int:
|
||||
return len(self._items)
|
||||
|
||||
|
||||
class DictBasedKeyValue(KeyValue[K, V]):
|
||||
"""KeyValue implementation backed by a plain dictionary."""
|
||||
|
||||
def __init__(self, data: Optional[Mapping[K, V]] = None):
|
||||
self._values: Dict[K, V] = dict(data) if data else {}
|
||||
|
||||
async def has(self, key: K) -> bool:
|
||||
return key in self._values
|
||||
|
||||
async def get(self, key: K, default: V | None = None) -> V | None:
|
||||
return self._values.get(key, default)
|
||||
|
||||
async def set(self, key: K, value: V) -> None:
|
||||
self._values[key] = value
|
||||
|
||||
async def pop(self, key: K, default: V | None = None) -> V | None:
|
||||
return self._values.pop(key, default)
|
||||
|
||||
async def size(self) -> int:
|
||||
return len(self._values)
|
||||
|
||||
|
||||
class InMemoryLightningCollections(LightningCollections):
|
||||
"""In-memory implementation of LightningCollections using Python data structures.
|
||||
|
||||
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"]
|
||||
)
|
||||
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
|
||||
|
||||
@property
|
||||
def rollouts(self) -> ListBasedCollection[Rollout]:
|
||||
return self._rollouts
|
||||
|
||||
@property
|
||||
def attempts(self) -> ListBasedCollection[Attempt]:
|
||||
return self._attempts
|
||||
|
||||
@property
|
||||
def spans(self) -> ListBasedCollection[Span]:
|
||||
return self._spans
|
||||
|
||||
@property
|
||||
def resources(self) -> ListBasedCollection[ResourcesUpdate]:
|
||||
return self._resources
|
||||
|
||||
@property
|
||||
def workers(self) -> ListBasedCollection[Worker]:
|
||||
return self._workers
|
||||
|
||||
@property
|
||||
def rollout_queue(self) -> DequeBasedQueue[str]:
|
||||
return self._rollout_queue
|
||||
|
||||
@property
|
||||
def span_sequence_ids(self) -> DictBasedKeyValue[str, int]:
|
||||
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 evict_spans_for_rollout(self, rollout_id: str) -> None:
|
||||
"""Evict all spans for a given rollout ID.
|
||||
|
||||
Uses private API for efficiency.
|
||||
"""
|
||||
self._spans._items.pop(rollout_id, []) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
class _LoopAwareAsyncLock:
|
||||
"""Async lock that transparently rebinds to the current event loop.
|
||||
|
||||
The lock intentionally remains *thread-unsafe*: callers must only use it from
|
||||
one thread at a time. If multiple threads interact with the store, each
|
||||
thread gets its own event loop specific lock.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._locks: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = weakref.WeakKeyDictionary()
|
||||
|
||||
# When serializing and deserializing, we don't need to serialize the locks.
|
||||
# Because another process will have its own set of event loops and its own lock.
|
||||
def __getstate__(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
self._locks = weakref.WeakKeyDictionary()
|
||||
|
||||
def _get_lock_for_current_loop(self) -> asyncio.Lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
lock = self._locks.get(loop)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._locks[loop] = lock
|
||||
return lock
|
||||
|
||||
async def __aenter__(self) -> asyncio.Lock:
|
||||
lock = self._get_lock_for_current_loop()
|
||||
await lock.acquire()
|
||||
return lock
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
lock = self._locks.get(loop)
|
||||
if lock is None or not lock.locked():
|
||||
raise RuntimeError("Lock released without being acquired")
|
||||
lock.release()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+247
-642
@@ -3,701 +3,306 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from typing import Any, Callable, Counter, Dict, List, Literal, Optional, Sequence, TypeVar, cast
|
||||
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
|
||||
from agentlightning.types import (
|
||||
Attempt,
|
||||
AttemptedRollout,
|
||||
AttemptStatus,
|
||||
NamedResources,
|
||||
ResourcesUpdate,
|
||||
Rollout,
|
||||
RolloutConfig,
|
||||
RolloutStatus,
|
||||
Span,
|
||||
TaskInput,
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Mapping as MappingABC
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Counter,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
Optional,
|
||||
Set,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from .base import UNSET, LightningStore, Unset, is_finished, is_queuing
|
||||
from .utils import healthcheck, propagate_status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agentlightning.types import AttemptedRollout, PaginatedResult, Rollout, Span
|
||||
|
||||
from .base import UNSET, LightningStoreCapabilities, Unset, is_finished, is_running
|
||||
from .collection import InMemoryLightningCollections
|
||||
from .collection_based import CollectionBasedLightningStore
|
||||
|
||||
T_callable = TypeVar("T_callable", bound=Callable[..., Any])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _healthcheck_wrapper(func: T_callable) -> T_callable:
|
||||
"""
|
||||
Decorator to run the watchdog healthcheck **before** executing the decorated method.
|
||||
Only runs if the store has a watchdog configured.
|
||||
Prevents recursive healthcheck execution using a flag on the store instance.
|
||||
"""
|
||||
def estimate_model_size(obj: Any) -> int:
|
||||
"""Rough recursive size estimate for Pydantic BaseModel instances."""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(self: InMemoryLightningStore, *args: Any, **kwargs: Any) -> Any:
|
||||
# Check if healthcheck is already running to prevent recursion
|
||||
if getattr(self, "_healthcheck_running", False):
|
||||
# Skip healthcheck if already running
|
||||
return await func(self, *args, **kwargs)
|
||||
|
||||
# Set flag to prevent recursive healthcheck calls
|
||||
# This flag is not asyncio/thread-safe, but it doesn't matter
|
||||
self._healthcheck_running = True # type: ignore
|
||||
try:
|
||||
# The following methods should live inside one lock.
|
||||
await self._healthcheck() # pyright: ignore[reportPrivateUsage]
|
||||
finally:
|
||||
# Always clear the flag, even if healthcheck fails
|
||||
self._healthcheck_running = False # type: ignore
|
||||
|
||||
# Execute the original method
|
||||
# This should be outside the lock.
|
||||
return await func(self, *args, **kwargs)
|
||||
|
||||
return cast(T_callable, wrapper)
|
||||
if isinstance(obj, BaseModel):
|
||||
values = cast(Iterable[Any], obj.__dict__.values())
|
||||
return sum(estimate_model_size(value) for value in values) + sys.getsizeof(cast(object, obj))
|
||||
if isinstance(obj, MappingABC):
|
||||
mapping = cast(Mapping[Any, Any], obj)
|
||||
return sum(estimate_model_size(value) for value in mapping.values()) + sys.getsizeof(cast(object, obj))
|
||||
if isinstance(obj, (list, tuple, set)):
|
||||
iterable = cast(Iterable[Any], obj)
|
||||
return sum(estimate_model_size(value) for value in iterable) + sys.getsizeof(cast(object, obj))
|
||||
return sys.getsizeof(cast(object, obj))
|
||||
|
||||
|
||||
def _generate_resources_id() -> str:
|
||||
short_id = hashlib.sha1(uuid.uuid4().bytes).hexdigest()[:12]
|
||||
return "rs-" + short_id
|
||||
def _detect_total_memory_bytes() -> int:
|
||||
"""Best-effort detection of the total available system memory in bytes."""
|
||||
|
||||
try:
|
||||
import psutil
|
||||
|
||||
return int(psutil.virtual_memory().total)
|
||||
except ImportError:
|
||||
# Fallback to 8GB if memory cannot be detected.
|
||||
logger.error("psutil is not installed. Falling back to 8GB of memory in total.")
|
||||
return 8 * 1024**3
|
||||
|
||||
|
||||
def _generate_rollout_id() -> str:
|
||||
short_id = hashlib.sha1(uuid.uuid4().bytes).hexdigest()[:12]
|
||||
return "ro-" + short_id
|
||||
|
||||
|
||||
def _generate_attempt_id() -> str:
|
||||
"""We don't need that long because attempts are limited to rollouts."""
|
||||
short_id = hashlib.sha1(uuid.uuid4().bytes).hexdigest()[:8]
|
||||
return "at-" + short_id
|
||||
|
||||
|
||||
class InMemoryLightningStore(LightningStore):
|
||||
class InMemoryLightningStore(CollectionBasedLightningStore[InMemoryLightningCollections]):
|
||||
"""
|
||||
In-memory implementation of LightningStore using Python data structures.
|
||||
Thread-safe and async-compatible but data is not persistent.
|
||||
|
||||
The methods in this class should generally not call each other,
|
||||
especially those that are locked.
|
||||
Args:
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = asyncio.Lock()
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
eviction_memory_threshold: float | int | None = None,
|
||||
safe_memory_threshold: float | int | None = None,
|
||||
span_size_estimator: Callable[[Span], int] | None = None,
|
||||
):
|
||||
super().__init__(collections=InMemoryLightningCollections())
|
||||
|
||||
# Task queue and rollouts storage
|
||||
self._task_queue: deque[Rollout] = deque()
|
||||
self._rollouts: Dict[str, Rollout] = {}
|
||||
self._start_time_by_rollout: Dict[str, float] = {}
|
||||
self._span_bytes_by_rollout: Dict[str, int] = Counter()
|
||||
self._total_span_bytes: int = 0
|
||||
self._evicted_rollout_span_sets: Set[str] = set()
|
||||
|
||||
# Resources storage (similar to legacy server.py)
|
||||
self._resources: Dict[str, ResourcesUpdate] = {}
|
||||
self._latest_resources_id: Optional[str] = None
|
||||
self._memory_capacity_bytes = _detect_total_memory_bytes()
|
||||
if self._memory_capacity_bytes <= 0:
|
||||
raise ValueError("Detected memory capacity must be positive")
|
||||
|
||||
# Spans storage
|
||||
self._spans: Dict[str, List[Span]] = {} # rollout_id -> list of spans
|
||||
self._span_sequence_ids: Dict[str, int] = Counter() # rollout_id -> sequence_id
|
||||
self._eviction_threshold_bytes = self._resolve_memory_threshold(
|
||||
eviction_memory_threshold,
|
||||
default_ratio=0.7,
|
||||
capacity_bytes=self._memory_capacity_bytes,
|
||||
name="eviction_memory_threshold",
|
||||
minimum=1,
|
||||
)
|
||||
|
||||
# Attempt tracking
|
||||
self._attempts: Dict[str, List[Attempt]] = {} # rollout_id -> list of attempts
|
||||
if safe_memory_threshold is None:
|
||||
safe_memory_threshold = max(int(self._eviction_threshold_bytes * 0.8), 0)
|
||||
|
||||
self._safe_threshold_bytes = self._resolve_memory_threshold(
|
||||
safe_memory_threshold,
|
||||
default_ratio=self._eviction_threshold_bytes / self._memory_capacity_bytes,
|
||||
capacity_bytes=self._memory_capacity_bytes,
|
||||
name="safe_memory_threshold",
|
||||
minimum=0,
|
||||
)
|
||||
|
||||
if not (0 <= self._safe_threshold_bytes < self._eviction_threshold_bytes):
|
||||
raise ValueError("safe_memory_threshold must be smaller than eviction_memory_threshold")
|
||||
self._custom_span_size_estimator = span_size_estimator
|
||||
|
||||
# Completion tracking for wait_for_rollouts (cross-loop safe)
|
||||
self._completion_events: Dict[str, threading.Event] = {}
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def start_rollout(
|
||||
self,
|
||||
input: TaskInput,
|
||||
mode: Literal["train", "val", "test"] | None = None,
|
||||
resources_id: str | None = None,
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> AttemptedRollout:
|
||||
"""
|
||||
Notify the store that I'm about to run a rollout.
|
||||
"""
|
||||
async with self._lock:
|
||||
rollout_id = _generate_rollout_id()
|
||||
current_time = time.time()
|
||||
# Running rollouts cache, including preparing and running rollouts
|
||||
self._running_rollout_ids: Set[str] = set()
|
||||
|
||||
rollout = Rollout(
|
||||
rollout_id=rollout_id,
|
||||
input=input,
|
||||
mode=mode,
|
||||
resources_id=resources_id or self._latest_resources_id,
|
||||
start_time=current_time,
|
||||
status="preparing",
|
||||
metadata=metadata or {},
|
||||
)
|
||||
# Caches the latest resources ID.
|
||||
self._latest_resources_id: Union[str, None, Unset] = UNSET
|
||||
|
||||
# Create the initial attempt
|
||||
attempt_id = _generate_attempt_id()
|
||||
attempt = Attempt(
|
||||
rollout_id=rollout.rollout_id,
|
||||
attempt_id=attempt_id,
|
||||
sequence_id=1,
|
||||
start_time=current_time,
|
||||
status="preparing",
|
||||
)
|
||||
@property
|
||||
def capabilities(self) -> LightningStoreCapabilities:
|
||||
"""Return the capabilities of the store."""
|
||||
return LightningStoreCapabilities(
|
||||
thread_safe=False,
|
||||
async_safe=True,
|
||||
zero_copy=False,
|
||||
otlp_traces=False,
|
||||
)
|
||||
|
||||
self._attempts[rollout.rollout_id] = [attempt]
|
||||
self._rollouts[rollout.rollout_id] = 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:
|
||||
rollout = await collections.rollouts.get({"rollout_id": {"exact": rollout_id}})
|
||||
if rollout and is_finished(rollout):
|
||||
return rollout
|
||||
|
||||
# Manully added rollout is not added to task queue. It's already preparing
|
||||
self._completion_events.setdefault(rollout.rollout_id, threading.Event())
|
||||
|
||||
return AttemptedRollout(**rollout.model_dump(), attempt=attempt)
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def enqueue_rollout(
|
||||
self,
|
||||
input: TaskInput,
|
||||
mode: Literal["train", "val", "test"] | None = None,
|
||||
resources_id: str | None = None,
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> Rollout:
|
||||
"""
|
||||
Adds a new task to the queue with specific metadata and returns its unique ID.
|
||||
"""
|
||||
async with self._lock:
|
||||
rollout_id = _generate_rollout_id()
|
||||
current_time = time.time()
|
||||
|
||||
rollout = Rollout(
|
||||
rollout_id=rollout_id,
|
||||
input=input,
|
||||
mode=mode,
|
||||
resources_id=resources_id or self._latest_resources_id,
|
||||
start_time=current_time,
|
||||
status="queuing", # should be queuing
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
self._rollouts[rollout.rollout_id] = rollout
|
||||
self._task_queue.append(rollout) # add it to the end of the queue
|
||||
self._completion_events.setdefault(rollout.rollout_id, threading.Event())
|
||||
|
||||
return rollout
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def dequeue_rollout(self) -> Optional[AttemptedRollout]:
|
||||
"""
|
||||
Retrieves the next task from the queue without blocking.
|
||||
Returns None if the queue is empty.
|
||||
|
||||
Will set the rollout status to preparing and create a new attempt.
|
||||
"""
|
||||
async with self._lock:
|
||||
# Keep looking until we find a rollout that's still in queuing status
|
||||
# or the queue is empty
|
||||
while self._task_queue:
|
||||
rollout = self._task_queue.popleft()
|
||||
|
||||
# Check if rollout is still in a queuing state
|
||||
# (it might have been updated to a different status while in queue)
|
||||
if is_queuing(rollout):
|
||||
# Update status to preparing
|
||||
rollout.status = "preparing"
|
||||
|
||||
# Create a new attempt (could be first attempt or retry)
|
||||
attempt_id = _generate_attempt_id()
|
||||
current_time = time.time()
|
||||
|
||||
# Get existing attempts to determine sequence number
|
||||
existing_attempts = self._attempts.get(rollout.rollout_id, [])
|
||||
sequence_id = len(existing_attempts) + 1
|
||||
|
||||
attempt = Attempt(
|
||||
rollout_id=rollout.rollout_id,
|
||||
attempt_id=attempt_id,
|
||||
sequence_id=sequence_id,
|
||||
start_time=current_time,
|
||||
status="preparing",
|
||||
)
|
||||
|
||||
if rollout.rollout_id not in self._attempts:
|
||||
self._attempts[rollout.rollout_id] = []
|
||||
self._attempts[rollout.rollout_id].append(attempt)
|
||||
|
||||
return AttemptedRollout(**rollout.model_dump(), attempt=attempt)
|
||||
|
||||
# If not in queuing state, skip this rollout and continue
|
||||
# (it was updated externally and should not be processed)
|
||||
|
||||
# No valid rollouts found
|
||||
if timeout is not None and timeout <= 0:
|
||||
return None
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
|
||||
"""
|
||||
Create a new attempt for a given rollout ID and return the attempt details.
|
||||
"""
|
||||
async with self._lock:
|
||||
# Get the rollout
|
||||
rollout = self._rollouts.get(rollout_id)
|
||||
if not rollout:
|
||||
raise ValueError(f"Rollout {rollout_id} not found")
|
||||
# If not completed and we have an event, wait for completion
|
||||
if rollout_id in self._completion_events:
|
||||
evt = self._completion_events[rollout_id]
|
||||
|
||||
# Get existing attempts to determine sequence number
|
||||
existing_attempts = self._attempts.get(rollout_id, [])
|
||||
sequence_id = len(existing_attempts) + 1
|
||||
|
||||
# We don't care whether the max attempts have reached or not
|
||||
# This attempt is from user trigger
|
||||
|
||||
# Create new attempt
|
||||
attempt_id = _generate_attempt_id()
|
||||
current_time = time.time()
|
||||
|
||||
attempt = Attempt(
|
||||
rollout_id=rollout_id,
|
||||
attempt_id=attempt_id,
|
||||
sequence_id=sequence_id,
|
||||
start_time=current_time,
|
||||
status="preparing",
|
||||
)
|
||||
|
||||
# Add attempt to storage
|
||||
if rollout_id not in self._attempts:
|
||||
self._attempts[rollout_id] = []
|
||||
self._attempts[rollout_id].append(attempt)
|
||||
|
||||
self._completion_events.setdefault(rollout.rollout_id, threading.Event())
|
||||
|
||||
return AttemptedRollout(**rollout.model_dump(), attempt=attempt)
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def query_rollouts(
|
||||
self, *, status: Optional[Sequence[RolloutStatus]] = None, rollout_ids: Optional[Sequence[str]] = None
|
||||
) -> List[Rollout]:
|
||||
"""
|
||||
Query and retrieve rollouts filtered by their status and rollout ids.
|
||||
If no status is provided, returns all rollouts.
|
||||
"""
|
||||
async with self._lock:
|
||||
rollouts = list(self._rollouts.values())
|
||||
|
||||
# Filter by rollout_ids if provided
|
||||
if rollout_ids is not None:
|
||||
rollout_ids_set = set(rollout_ids)
|
||||
rollouts = [rollout for rollout in rollouts if rollout.rollout_id in rollout_ids_set]
|
||||
|
||||
# Filter by status if provided
|
||||
if status is not None:
|
||||
status_set = set(status)
|
||||
rollouts = [rollout for rollout in rollouts if rollout.status in status_set]
|
||||
|
||||
return rollouts
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def get_rollout_by_id(self, rollout_id: str) -> Optional[Rollout]:
|
||||
"""
|
||||
Safely retrieves a specific rollout by its ID.
|
||||
"""
|
||||
async with self._lock:
|
||||
return self._rollouts.get(rollout_id)
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def query_attempts(self, rollout_id: str) -> List[Attempt]:
|
||||
"""
|
||||
Query and retrieve all attempts associated with a specific rollout ID.
|
||||
Returns an empty list if no attempts are found.
|
||||
"""
|
||||
async with self._lock:
|
||||
return self._attempts.get(rollout_id, [])
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def get_latest_attempt(self, rollout_id: str) -> Optional[Attempt]:
|
||||
"""
|
||||
Safely retrieves the latest attempt for a given rollout ID.
|
||||
"""
|
||||
async with self._lock:
|
||||
attempts = self._attempts.get(rollout_id, [])
|
||||
if not attempts:
|
||||
return None
|
||||
return max(attempts, key=lambda a: a.sequence_id)
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def add_resources(self, resources: NamedResources) -> ResourcesUpdate:
|
||||
"""
|
||||
Safely stores a new version of named resources and sets it as the latest.
|
||||
"""
|
||||
resources_id = _generate_resources_id()
|
||||
async with self._lock:
|
||||
update = ResourcesUpdate(resources_id=resources_id, resources=resources)
|
||||
self._resources[resources_id] = update
|
||||
self._latest_resources_id = resources_id
|
||||
return update
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def update_resources(self, resources_id: str, resources: NamedResources) -> ResourcesUpdate:
|
||||
"""
|
||||
Safely stores a new version of named resources and sets it as the latest.
|
||||
"""
|
||||
async with self._lock:
|
||||
update = ResourcesUpdate(resources_id=resources_id, resources=resources)
|
||||
self._resources[resources_id] = update
|
||||
self._latest_resources_id = resources_id
|
||||
return update
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def get_resources_by_id(self, resources_id: str) -> Optional[ResourcesUpdate]:
|
||||
"""
|
||||
Safely retrieves a specific version of named resources by its ID.
|
||||
"""
|
||||
async with self._lock:
|
||||
return self._resources.get(resources_id)
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def get_latest_resources(self) -> Optional[ResourcesUpdate]:
|
||||
"""
|
||||
Safely retrieves the latest version of named resources.
|
||||
"""
|
||||
async with self._lock:
|
||||
if self._latest_resources_id:
|
||||
return self._resources.get(self._latest_resources_id)
|
||||
return None
|
||||
|
||||
async def get_next_span_sequence_id(self, rollout_id: str, attempt_id: str) -> int:
|
||||
"""
|
||||
Get the next span sequence ID for a given rollout and attempt.
|
||||
The number is strictly increasing for each rollout.
|
||||
The store will not issue the same sequence ID twice.
|
||||
"""
|
||||
async with self._lock:
|
||||
self._span_sequence_ids[rollout_id] += 1
|
||||
return self._span_sequence_ids[rollout_id]
|
||||
|
||||
async def add_span(self, span: Span) -> Span:
|
||||
"""Persist a pre-converted span."""
|
||||
async with self._lock:
|
||||
self._span_sequence_ids[span.rollout_id] = max(self._span_sequence_ids[span.rollout_id], span.sequence_id)
|
||||
return await self._add_span_unlocked(span)
|
||||
|
||||
async def add_otel_span(
|
||||
self, rollout_id: str, attempt_id: str, readable_span: ReadableSpan, sequence_id: int | None = None
|
||||
) -> Span:
|
||||
"""Add an opentelemetry span to the store."""
|
||||
async with self._lock:
|
||||
if sequence_id is None:
|
||||
# Issue a new sequence ID for the rollout
|
||||
self._span_sequence_ids[rollout_id] += 1
|
||||
sequence_id = self._span_sequence_ids[rollout_id]
|
||||
# Wait for the event with proper timeout handling
|
||||
# evt.wait() returns True if event was set, False if timeout occurred
|
||||
if timeout is None:
|
||||
# Wait indefinitely by polling with finite timeouts
|
||||
# This allows threads to exit cleanly on shutdown
|
||||
while True:
|
||||
result = await asyncio.to_thread(evt.wait, 10.0) # Poll every 10 seconds
|
||||
if result: # Event was set
|
||||
break
|
||||
# Loop and check again (continues indefinitely since timeout=None)
|
||||
else:
|
||||
# Comes from a provided sequence ID
|
||||
# Make sure our counter is strictly increasing
|
||||
self._span_sequence_ids[rollout_id] = max(self._span_sequence_ids[rollout_id], sequence_id)
|
||||
# Wait with the specified timeout
|
||||
result = await asyncio.to_thread(evt.wait, timeout)
|
||||
|
||||
span = Span.from_opentelemetry(
|
||||
readable_span, rollout_id=rollout_id, attempt_id=attempt_id, sequence_id=sequence_id
|
||||
# If event was set (not timeout), check if rollout is finished
|
||||
if result:
|
||||
async with self.collections.atomic() as collections:
|
||||
rollout = await collections.rollouts.get({"rollout_id": {"exact": rollout_id}})
|
||||
if rollout and is_finished(rollout):
|
||||
return rollout
|
||||
|
||||
return None
|
||||
|
||||
async def on_rollout_update(self, rollout: Rollout) -> 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)
|
||||
|
||||
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 rollout.rollout_id not in self._start_time_by_rollout:
|
||||
self._start_time_by_rollout[rollout.rollout_id] = rollout.start_time
|
||||
|
||||
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"},
|
||||
)
|
||||
await self._add_span_unlocked(span)
|
||||
return span
|
||||
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
|
||||
|
||||
async def _add_span_unlocked(self, span: Span) -> Span:
|
||||
rollout = self._rollouts.get(span.rollout_id)
|
||||
if not rollout:
|
||||
raise ValueError(f"Rollout {span.rollout_id} not found")
|
||||
attempts = self._attempts.get(span.rollout_id, [])
|
||||
current_attempt = next((a for a in attempts if a.attempt_id == span.attempt_id), None)
|
||||
latest_attempt = max(attempts, key=lambda a: a.sequence_id) if attempts else None
|
||||
if not current_attempt:
|
||||
raise ValueError(f"Attempt {span.attempt_id} not found for rollout {span.rollout_id}")
|
||||
if not latest_attempt:
|
||||
raise ValueError(f"No attempts found for rollout {span.rollout_id}")
|
||||
async def query_spans(
|
||||
self,
|
||||
rollout_id: str,
|
||||
attempt_id: str | Literal["latest"] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> PaginatedResult[Span]:
|
||||
if rollout_id in self._evicted_rollout_span_sets:
|
||||
raise RuntimeError(f"Spans for rollout {rollout_id} have been evicted")
|
||||
return await super().query_spans(rollout_id, attempt_id, **kwargs)
|
||||
|
||||
if span.rollout_id not in self._spans:
|
||||
self._spans[span.rollout_id] = []
|
||||
self._spans[span.rollout_id].append(span)
|
||||
async def _add_span_unlocked(self, collections: InMemoryLightningCollections, span: Span) -> Span:
|
||||
"""In-memory store needs to maintain the span data in memory, and evict spans when memory is low."""
|
||||
|
||||
# Update attempt heartbeat
|
||||
current_attempt.last_heartbeat_time = time.time()
|
||||
if current_attempt.status in ["preparing", "unresponsive", "timeout"]:
|
||||
current_attempt.status = "running"
|
||||
|
||||
# If the status has already timed out or failed, do not change it
|
||||
|
||||
# Update rollout status if it's the latest attempt
|
||||
if current_attempt == latest_attempt:
|
||||
if rollout.status == "preparing":
|
||||
rollout.status = "running"
|
||||
elif rollout.status in ["queuing", "requeuing"]:
|
||||
try:
|
||||
self._task_queue.remove(rollout)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Trying to remove rollout {rollout.rollout_id} from the queue but it's not in the queue."
|
||||
)
|
||||
rollout.status = "running"
|
||||
await super()._add_span_unlocked(collections, span)
|
||||
self._account_span_size(span)
|
||||
await self._maybe_evict_spans(collections)
|
||||
|
||||
return span
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def wait_for_rollouts(self, *, rollout_ids: List[str], timeout: Optional[float] = None) -> List[Rollout]:
|
||||
"""
|
||||
Wait for specified rollouts to complete with a timeout.
|
||||
Returns the completed rollouts, potentially incomplete if timeout is reached.
|
||||
|
||||
This method does not change the state of the store.
|
||||
"""
|
||||
completed_rollouts: List[Rollout] = []
|
||||
|
||||
async def wait_for_rollout(rollout_id: str):
|
||||
# First check if already completed
|
||||
async with self._lock:
|
||||
rollout = self._rollouts.get(rollout_id)
|
||||
if rollout and is_finished(rollout):
|
||||
completed_rollouts.append(rollout)
|
||||
return
|
||||
|
||||
# No timeout, return immediately
|
||||
if timeout is not None and timeout <= 0:
|
||||
return
|
||||
|
||||
# If not completed and we have an event, wait for completion
|
||||
if rollout_id in self._completion_events:
|
||||
evt = self._completion_events[rollout_id]
|
||||
|
||||
# Wait for the event with proper timeout handling
|
||||
# evt.wait() returns True if event was set, False if timeout occurred
|
||||
if timeout is None:
|
||||
# Wait indefinitely by polling with finite timeouts
|
||||
# This allows threads to exit cleanly on shutdown
|
||||
while True:
|
||||
result = await asyncio.to_thread(evt.wait, 10.0) # Poll every 10 seconds
|
||||
if result: # Event was set
|
||||
break
|
||||
# Loop and check again (continues indefinitely since timeout=None)
|
||||
else:
|
||||
# Wait with the specified timeout
|
||||
result = await asyncio.to_thread(evt.wait, timeout)
|
||||
|
||||
# If event was set (not timeout), check if rollout is finished
|
||||
if result:
|
||||
async with self._lock:
|
||||
rollout = self._rollouts.get(rollout_id)
|
||||
if rollout and is_finished(rollout):
|
||||
completed_rollouts.append(rollout)
|
||||
|
||||
# Rollout not found, return
|
||||
|
||||
# Wait for all rollouts concurrently
|
||||
await asyncio.gather(*[wait_for_rollout(rid) for rid in rollout_ids], return_exceptions=True)
|
||||
|
||||
return completed_rollouts
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def query_spans(self, rollout_id: str, attempt_id: str | Literal["latest"] | None = None) -> List[Span]:
|
||||
"""
|
||||
Query and retrieve all spans associated with a specific rollout ID.
|
||||
Returns an empty list if no spans are found.
|
||||
"""
|
||||
async with self._lock:
|
||||
spans = self._spans.get(rollout_id, [])
|
||||
if attempt_id is None:
|
||||
return spans
|
||||
elif attempt_id == "latest":
|
||||
# Find the latest attempt_id
|
||||
if not spans:
|
||||
return []
|
||||
latest_attempt = max(spans, key=lambda s: s.sequence_id if s.attempt_id else "").attempt_id
|
||||
return [s for s in spans if s.attempt_id == latest_attempt]
|
||||
async def _get_latest_resources_id(self, collections: InMemoryLightningCollections) -> Optional[str]:
|
||||
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:
|
||||
return [s for s in spans if s.attempt_id == attempt_id]
|
||||
self._latest_resources_id = None
|
||||
return self._latest_resources_id
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def update_rollout(
|
||||
self,
|
||||
rollout_id: str,
|
||||
input: TaskInput | Unset = UNSET,
|
||||
mode: Optional[Literal["train", "val", "test"]] | Unset = UNSET,
|
||||
resources_id: Optional[str] | Unset = UNSET,
|
||||
status: RolloutStatus | Unset = UNSET,
|
||||
config: RolloutConfig | Unset = UNSET,
|
||||
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
|
||||
) -> Rollout:
|
||||
"""
|
||||
Update the rollout status and related metadata.
|
||||
"""
|
||||
async with self._lock:
|
||||
return await self._update_rollout_unlocked(
|
||||
rollout_id=rollout_id,
|
||||
input=input,
|
||||
mode=mode,
|
||||
resources_id=resources_id,
|
||||
status=status,
|
||||
config=config,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@_healthcheck_wrapper
|
||||
async def update_attempt(
|
||||
self,
|
||||
rollout_id: str,
|
||||
attempt_id: str | Literal["latest"],
|
||||
status: AttemptStatus | Unset = UNSET,
|
||||
worker_id: str | Unset = UNSET,
|
||||
last_heartbeat_time: float | Unset = UNSET,
|
||||
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
|
||||
) -> Attempt:
|
||||
"""
|
||||
Update a specific or latest attempt for a given rollout.
|
||||
"""
|
||||
async with self._lock:
|
||||
attempt = await self._update_attempt_unlocked(
|
||||
rollout_id=rollout_id,
|
||||
attempt_id=attempt_id,
|
||||
status=status,
|
||||
worker_id=worker_id,
|
||||
last_heartbeat_time=last_heartbeat_time,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
return attempt
|
||||
|
||||
async def _update_rollout_unlocked(
|
||||
self,
|
||||
rollout_id: str,
|
||||
input: TaskInput | Unset = UNSET,
|
||||
mode: Optional[Literal["train", "val", "test"]] | Unset = UNSET,
|
||||
resources_id: Optional[str] | Unset = UNSET,
|
||||
status: RolloutStatus | Unset = UNSET,
|
||||
config: RolloutConfig | Unset = UNSET,
|
||||
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
|
||||
) -> Rollout:
|
||||
# No lock inside this one.
|
||||
rollout = self._rollouts.get(rollout_id)
|
||||
if not rollout:
|
||||
raise ValueError(f"Rollout {rollout_id} not found")
|
||||
|
||||
# Update fields if they are not UNSET
|
||||
if not isinstance(input, Unset):
|
||||
rollout.input = input
|
||||
if not isinstance(mode, Unset):
|
||||
rollout.mode = mode
|
||||
if not isinstance(resources_id, Unset):
|
||||
rollout.resources_id = resources_id
|
||||
if not isinstance(status, Unset):
|
||||
rollout.status = status
|
||||
if not isinstance(config, Unset):
|
||||
rollout.config = config
|
||||
if not isinstance(metadata, Unset):
|
||||
rollout.metadata = metadata
|
||||
|
||||
# Set end time for finished rollouts
|
||||
# Rollout is only finished when it succeeded or fail with no more retries.
|
||||
if not isinstance(status, Unset) and is_finished(rollout):
|
||||
rollout.end_time = time.time()
|
||||
# Signal completion
|
||||
if rollout_id in self._completion_events:
|
||||
self._completion_events[rollout_id].set()
|
||||
|
||||
# If requeuing, add back to queue
|
||||
elif is_queuing(rollout) and rollout not in self._task_queue:
|
||||
self._task_queue.append(rollout)
|
||||
|
||||
# If the rollout is no longer in a queueing state, remove it from the queue.
|
||||
if not isinstance(status, Unset) and not is_queuing(rollout) and rollout in self._task_queue:
|
||||
try:
|
||||
self._task_queue.remove(rollout)
|
||||
except ValueError:
|
||||
# Another coroutine may have already removed the rollout from the queue.
|
||||
logger.warning(
|
||||
f"Trying to remove rollout {rollout.rollout_id} from the queue but it's not in the queue."
|
||||
)
|
||||
|
||||
# Re-validate the rollout to ensure legality
|
||||
Rollout.model_validate(rollout.model_dump())
|
||||
|
||||
return rollout
|
||||
|
||||
async def _update_attempt_unlocked(
|
||||
self,
|
||||
rollout_id: str,
|
||||
attempt_id: str | Literal["latest"],
|
||||
status: AttemptStatus | Unset = UNSET,
|
||||
worker_id: str | Unset = UNSET,
|
||||
last_heartbeat_time: float | Unset = UNSET,
|
||||
metadata: Optional[Dict[str, Any]] | Unset = UNSET,
|
||||
) -> Attempt:
|
||||
# No lock, but with status propagation.
|
||||
rollout = self._rollouts.get(rollout_id)
|
||||
if not rollout:
|
||||
raise ValueError(f"Rollout {rollout_id} not found")
|
||||
|
||||
attempts = self._attempts.get(rollout_id, [])
|
||||
if not attempts:
|
||||
raise ValueError(f"No attempts found for rollout {rollout_id}")
|
||||
|
||||
latest_attempt = max(attempts, key=lambda a: a.sequence_id)
|
||||
|
||||
# Find the attempt to update
|
||||
if attempt_id == "latest":
|
||||
attempt = latest_attempt
|
||||
@staticmethod
|
||||
def _resolve_memory_threshold(
|
||||
value: float | int | None,
|
||||
*,
|
||||
default_ratio: float,
|
||||
capacity_bytes: int,
|
||||
name: str,
|
||||
minimum: int,
|
||||
) -> int:
|
||||
if value is None:
|
||||
resolved = int(capacity_bytes * default_ratio)
|
||||
elif isinstance(value, float):
|
||||
if minimum == 0:
|
||||
if not (0 <= value <= 1):
|
||||
raise ValueError(f"{name} ratio must be between 0 and 1 inclusive")
|
||||
else:
|
||||
if not (0 < value <= 1):
|
||||
raise ValueError(f"{name} ratio must be greater than 0 and at most 1")
|
||||
resolved = int(capacity_bytes * value)
|
||||
else:
|
||||
attempt = next((a for a in attempts if a.attempt_id == attempt_id), None)
|
||||
if not attempt:
|
||||
raise ValueError(f"Attempt {attempt_id} not found for rollout {rollout_id}")
|
||||
value_int = value
|
||||
if value_int < 0:
|
||||
raise ValueError(f"{name} must be non-negative")
|
||||
resolved = value_int
|
||||
|
||||
# Update fields if they are not UNSET
|
||||
if not isinstance(status, Unset):
|
||||
attempt.status = status
|
||||
# Also update end_time if the status indicates completion
|
||||
if status in ["failed", "succeeded"]:
|
||||
attempt.end_time = time.time()
|
||||
if not isinstance(worker_id, Unset):
|
||||
attempt.worker_id = worker_id
|
||||
if not isinstance(last_heartbeat_time, Unset):
|
||||
attempt.last_heartbeat_time = last_heartbeat_time
|
||||
if not isinstance(metadata, Unset):
|
||||
attempt.metadata = metadata
|
||||
if resolved < minimum:
|
||||
raise ValueError(f"{name} must be at least {minimum} bytes")
|
||||
|
||||
# Re-validate the attempt to ensure legality
|
||||
Attempt.model_validate(attempt.model_dump())
|
||||
return resolved
|
||||
|
||||
if attempt == latest_attempt:
|
||||
def _account_span_size(self, span: Span) -> int:
|
||||
if self._custom_span_size_estimator is not None:
|
||||
size = max(int(self._custom_span_size_estimator(span)), 0)
|
||||
else:
|
||||
size = estimate_model_size(span)
|
||||
|
||||
async def _update_status(rollout_id: str, status: RolloutStatus) -> Rollout:
|
||||
return await self._update_rollout_unlocked(rollout_id, status=status)
|
||||
self._span_bytes_by_rollout[span.rollout_id] += size
|
||||
self._total_span_bytes += size
|
||||
return size
|
||||
|
||||
# Propagate the status to the rollout
|
||||
await propagate_status(
|
||||
_update_status,
|
||||
attempt,
|
||||
rollout.config,
|
||||
)
|
||||
async def _maybe_evict_spans(self, collections: InMemoryLightningCollections) -> None:
|
||||
if self._total_span_bytes <= self._eviction_threshold_bytes:
|
||||
return
|
||||
|
||||
return attempt
|
||||
logger.info(
|
||||
f"Total span bytes: {self._total_span_bytes}, eviction threshold: {self._eviction_threshold_bytes}, "
|
||||
f"safe threshold: {self._safe_threshold_bytes}. Evicting spans..."
|
||||
)
|
||||
candidates: List[tuple[float, str]] = [
|
||||
(start_time, rollout_id) for rollout_id, start_time in self._start_time_by_rollout.items()
|
||||
]
|
||||
candidates.sort()
|
||||
|
||||
async def _healthcheck(self) -> None:
|
||||
"""Perform healthcheck against all running rollouts in the store."""
|
||||
async with self._lock:
|
||||
running_rollouts: List[AttemptedRollout] = []
|
||||
for rollout in self._rollouts.values():
|
||||
if rollout.status in ["preparing", "running"]:
|
||||
all_attempts = self._attempts.get(rollout.rollout_id, [])
|
||||
if not all_attempts:
|
||||
# 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
|
||||
latest_attempt = max(all_attempts, key=lambda a: a.sequence_id)
|
||||
running_rollouts.append(AttemptedRollout(**rollout.model_dump(), attempt=latest_attempt))
|
||||
logger.info(f"Evicting spans for {len(candidates)} rollouts to free up memory...")
|
||||
memory_consumed_before = self._total_span_bytes
|
||||
for _, rollout_id in candidates:
|
||||
if self._total_span_bytes <= self._safe_threshold_bytes:
|
||||
break
|
||||
logger.debug(f"Evicting spans for rollout {rollout_id} to free up memory...")
|
||||
await self._evict_spans_for_rollout(collections, rollout_id)
|
||||
logger.info(f"Freed up {memory_consumed_before - self._total_span_bytes} bytes of memory")
|
||||
|
||||
async def _update_attempt_status(rollout_id: str, attempt_id: str, status: AttemptStatus) -> Attempt:
|
||||
return await self._update_attempt_unlocked(rollout_id, attempt_id, status=status)
|
||||
|
||||
async def _update_rollout_status(rollout_id: str, status: RolloutStatus) -> Rollout:
|
||||
return await self._update_rollout_unlocked(rollout_id, status=status)
|
||||
|
||||
await healthcheck(
|
||||
running_rollouts,
|
||||
_update_rollout_status,
|
||||
_update_attempt_status,
|
||||
)
|
||||
async def _evict_spans_for_rollout(self, collections: InMemoryLightningCollections, rollout_id: str) -> None:
|
||||
await collections.evict_spans_for_rollout(rollout_id)
|
||||
removed_bytes = self._span_bytes_by_rollout.pop(rollout_id, 0)
|
||||
if removed_bytes > 0:
|
||||
# There is something removed for real
|
||||
self._total_span_bytes = max(self._total_span_bytes - removed_bytes, 0)
|
||||
self._evicted_rollout_span_sets.add(rollout_id)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Mapping,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
from pymongo import AsyncMongoClient
|
||||
|
||||
from .base import LightningStoreCapabilities
|
||||
from .collection.mongo import MongoClientPool, MongoLightningCollections
|
||||
from .collection_based import CollectionBasedLightningStore
|
||||
|
||||
T_callable = TypeVar("T_callable", bound=Callable[..., Any])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _generate_partition_id() -> str:
|
||||
return "pt-" + hashlib.sha1(uuid.uuid4().bytes).hexdigest()[:12]
|
||||
|
||||
|
||||
class MongoLightningStore(CollectionBasedLightningStore[MongoLightningCollections]):
|
||||
"""
|
||||
MongoDB implementation of LightningStore using MongoDB collections.
|
||||
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.
|
||||
partition_id: The partition id. Useful when sharing the database among multiple Agent-lightning trainers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: AsyncMongoClient[Mapping[str, Any]] | str,
|
||||
database_name: str | None = None,
|
||||
partition_id: str | None = None,
|
||||
) -> None:
|
||||
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
|
||||
if database_name is None:
|
||||
database_name = "agentlightning"
|
||||
logger.info("No database name provided, using default 'agentlightning'")
|
||||
|
||||
if partition_id is None:
|
||||
partition_id = _generate_partition_id()
|
||||
logger.info("No partition id provided, generated a new one: %s", partition_id)
|
||||
|
||||
self._client_pool = MongoClientPool(self._client)
|
||||
|
||||
super().__init__(collections=MongoLightningCollections(self._client_pool, database_name, partition_id))
|
||||
|
||||
@property
|
||||
def capabilities(self) -> LightningStoreCapabilities:
|
||||
"""Return the capabilities of the store."""
|
||||
return LightningStoreCapabilities(
|
||||
thread_safe=True,
|
||||
async_safe=True,
|
||||
zero_copy=True,
|
||||
otlp_traces=False,
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -18,9 +18,11 @@ from agentlightning.types import (
|
||||
RolloutStatus,
|
||||
Span,
|
||||
TaskInput,
|
||||
Worker,
|
||||
WorkerStatus,
|
||||
)
|
||||
|
||||
from .base import UNSET, LightningStore, Unset
|
||||
from .base import UNSET, LightningStore, LightningStoreCapabilities, Unset
|
||||
|
||||
|
||||
class LightningStoreThreaded(LightningStore):
|
||||
@@ -35,29 +37,41 @@ class LightningStoreThreaded(LightningStore):
|
||||
self.store = store
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def capabilities(self) -> LightningStoreCapabilities:
|
||||
"""Return the capabilities of the store."""
|
||||
capabilities = self.store.capabilities
|
||||
return {
|
||||
**capabilities,
|
||||
"async_safe": True,
|
||||
"thread_safe": True,
|
||||
}
|
||||
|
||||
async def start_rollout(
|
||||
self,
|
||||
input: TaskInput,
|
||||
mode: Literal["train", "val", "test"] | None = None,
|
||||
resources_id: str | None = None,
|
||||
config: RolloutConfig | None = None,
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> AttemptedRollout:
|
||||
with self._lock:
|
||||
return await self.store.start_rollout(input, mode, resources_id, metadata)
|
||||
return await self.store.start_rollout(input, mode, resources_id, config, metadata)
|
||||
|
||||
async def enqueue_rollout(
|
||||
self,
|
||||
input: TaskInput,
|
||||
mode: Literal["train", "val", "test"] | None = None,
|
||||
resources_id: str | None = None,
|
||||
config: RolloutConfig | None = None,
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> Rollout:
|
||||
with self._lock:
|
||||
return await self.store.enqueue_rollout(input, mode, resources_id, metadata)
|
||||
return await self.store.enqueue_rollout(input, mode, resources_id, config, metadata)
|
||||
|
||||
async def dequeue_rollout(self) -> Optional[AttemptedRollout]:
|
||||
async def dequeue_rollout(self, worker_id: Optional[str] = None) -> Optional[AttemptedRollout]:
|
||||
with self._lock:
|
||||
return await self.store.dequeue_rollout()
|
||||
return await self.store.dequeue_rollout(worker_id=worker_id)
|
||||
|
||||
async def start_attempt(self, rollout_id: str) -> AttemptedRollout:
|
||||
with self._lock:
|
||||
@@ -66,15 +80,48 @@ class LightningStoreThreaded(LightningStore):
|
||||
async def query_rollouts(
|
||||
self,
|
||||
*,
|
||||
status_in: Optional[Sequence[RolloutStatus]] = None,
|
||||
rollout_id_in: Optional[Sequence[str]] = None,
|
||||
rollout_id_contains: Optional[str] = None,
|
||||
filter_logic: Literal["and", "or"] = "and",
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Literal["asc", "desc"] = "asc",
|
||||
limit: int = -1,
|
||||
offset: int = 0,
|
||||
status: Optional[Sequence[RolloutStatus]] = None,
|
||||
rollout_ids: Optional[Sequence[str]] = None,
|
||||
) -> List[Rollout]:
|
||||
) -> Sequence[Rollout]:
|
||||
with self._lock:
|
||||
return await self.store.query_rollouts(status=status, rollout_ids=rollout_ids)
|
||||
return await self.store.query_rollouts(
|
||||
status_in=status_in,
|
||||
rollout_id_in=rollout_id_in,
|
||||
rollout_id_contains=rollout_id_contains,
|
||||
filter_logic=filter_logic,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
status=status,
|
||||
rollout_ids=rollout_ids,
|
||||
)
|
||||
|
||||
async def query_attempts(self, rollout_id: str) -> List[Attempt]:
|
||||
async def query_attempts(
|
||||
self,
|
||||
rollout_id: str,
|
||||
*,
|
||||
sort_by: Optional[str] = "sequence_id",
|
||||
sort_order: Literal["asc", "desc"] = "asc",
|
||||
limit: int = -1,
|
||||
offset: int = 0,
|
||||
) -> Sequence[Attempt]:
|
||||
with self._lock:
|
||||
return await self.store.query_attempts(rollout_id)
|
||||
return await self.store.query_attempts(
|
||||
rollout_id,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
async def get_rollout_by_id(self, rollout_id: str) -> Optional[Rollout]:
|
||||
with self._lock:
|
||||
@@ -84,6 +131,26 @@ class LightningStoreThreaded(LightningStore):
|
||||
with self._lock:
|
||||
return await self.store.get_latest_attempt(rollout_id)
|
||||
|
||||
async def query_resources(
|
||||
self,
|
||||
*,
|
||||
resources_id: Optional[str] = None,
|
||||
resources_id_contains: Optional[str] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Literal["asc", "desc"] = "asc",
|
||||
limit: int = -1,
|
||||
offset: int = 0,
|
||||
) -> Sequence[ResourcesUpdate]:
|
||||
with self._lock:
|
||||
return await self.store.query_resources(
|
||||
resources_id=resources_id,
|
||||
resources_id_contains=resources_id_contains,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
async def add_resources(self, resources: NamedResources) -> ResourcesUpdate:
|
||||
with self._lock:
|
||||
return await self.store.add_resources(resources)
|
||||
@@ -126,9 +193,39 @@ class LightningStoreThreaded(LightningStore):
|
||||
self,
|
||||
rollout_id: str,
|
||||
attempt_id: str | Literal["latest"] | None = None,
|
||||
) -> List[Span]:
|
||||
*,
|
||||
trace_id: Optional[str] = None,
|
||||
trace_id_contains: Optional[str] = None,
|
||||
span_id: Optional[str] = None,
|
||||
span_id_contains: Optional[str] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
parent_id_contains: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
name_contains: Optional[str] = None,
|
||||
filter_logic: Literal["and", "or"] = "and",
|
||||
limit: int = -1,
|
||||
offset: int = 0,
|
||||
sort_by: Optional[str] = "sequence_id",
|
||||
sort_order: Literal["asc", "desc"] = "asc",
|
||||
) -> Sequence[Span]:
|
||||
with self._lock:
|
||||
return await self.store.query_spans(rollout_id, attempt_id)
|
||||
return await self.store.query_spans(
|
||||
rollout_id,
|
||||
attempt_id,
|
||||
trace_id=trace_id,
|
||||
trace_id_contains=trace_id_contains,
|
||||
span_id=span_id,
|
||||
span_id_contains=span_id_contains,
|
||||
parent_id=parent_id,
|
||||
parent_id_contains=parent_id_contains,
|
||||
name=name,
|
||||
name_contains=name_contains,
|
||||
filter_logic=filter_logic,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
async def update_rollout(
|
||||
self,
|
||||
@@ -169,3 +266,39 @@ class LightningStoreThreaded(LightningStore):
|
||||
last_heartbeat_time=last_heartbeat_time,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
async def query_workers(
|
||||
self,
|
||||
*,
|
||||
status_in: Optional[Sequence[WorkerStatus]] = None,
|
||||
worker_id_contains: Optional[str] = None,
|
||||
filter_logic: Literal["and", "or"] = "and",
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Literal["asc", "desc"] = "asc",
|
||||
limit: int = -1,
|
||||
offset: int = 0,
|
||||
) -> Sequence[Worker]:
|
||||
with self._lock:
|
||||
return await self.store.query_workers(
|
||||
status_in=status_in,
|
||||
worker_id_contains=worker_id_contains,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
async def get_worker_by_id(self, worker_id: str) -> Optional[Worker]:
|
||||
with self._lock:
|
||||
return await self.store.get_worker_by_id(worker_id)
|
||||
|
||||
async def update_worker(
|
||||
self,
|
||||
worker_id: str,
|
||||
heartbeat_stats: Dict[str, Any] | Unset = UNSET,
|
||||
) -> Worker:
|
||||
with self._lock:
|
||||
return await self.store.update_worker(
|
||||
worker_id=worker_id,
|
||||
heartbeat_stats=heartbeat_stats,
|
||||
)
|
||||
|
||||
@@ -57,6 +57,7 @@ async def healthcheck(
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from .agentops import AgentOpsTracer
|
||||
from .base import BaseTracer
|
||||
from .base import Tracer
|
||||
from .otel import OtelTracer
|
||||
|
||||
__all__ = ["AgentOpsTracer", "BaseTracer", "OtelTracer"]
|
||||
__all__ = ["AgentOpsTracer", "Tracer", "OtelTracer"]
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Iterator, List, Optional
|
||||
import warnings
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import TYPE_CHECKING, Any, AsyncGenerator, Iterator, List, Optional
|
||||
|
||||
import agentops
|
||||
import agentops.sdk.core
|
||||
import opentelemetry.trace as trace_api
|
||||
from agentops.sdk.core import TracingCore
|
||||
from agentops.sdk.processors import SpanProcessor
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
from opentelemetry.sdk.trace import TracerProvider as TracerProviderImpl
|
||||
from opentelemetry.trace import get_tracer_provider
|
||||
from opentelemetry.trace.status import StatusCode
|
||||
|
||||
from agentlightning.instrumentation import instrument_all, uninstrument_all
|
||||
from agentlightning.instrumentation.agentops import AgentOpsServerManager
|
||||
from agentlightning.store.base import LightningStore
|
||||
|
||||
from .base import BaseTracer
|
||||
from .otel import LightningSpanProcessor, OtelTracer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agentops.integration.callbacks.langchain import LangchainCallbackHandler
|
||||
@@ -28,7 +28,7 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentOpsTracer(BaseTracer):
|
||||
class AgentOpsTracer(OtelTracer):
|
||||
"""Traces agent execution using AgentOps.
|
||||
|
||||
This tracer provides functionality to capture execution details using the
|
||||
@@ -55,95 +55,37 @@ class AgentOpsTracer(BaseTracer):
|
||||
self.instrument_managed = instrument_managed
|
||||
self.daemon = daemon
|
||||
|
||||
self._agentops_server_manager = AgentOpsServerManager(self.daemon)
|
||||
self._agentops_server_port_val: Optional[int] = None
|
||||
|
||||
if not self.agentops_managed:
|
||||
logger.warning("agentops_managed=False. You are responsible for AgentOps setup.")
|
||||
if not self.instrument_managed:
|
||||
logger.warning("instrument_managed=False. You are responsible for all instrumentation.")
|
||||
|
||||
def __getstate__(self):
|
||||
state = self.__dict__.copy()
|
||||
state["_agentops_server_manager"] = None # Exclude the unpicklable server manager
|
||||
# _agentops_server_port_val (int) is inherently picklable and will be included.
|
||||
logger.debug(f"Getting state for pickling Trainer (PID {os.getpid()}). _agentops_server_manager excluded.")
|
||||
return state
|
||||
|
||||
def __setstate__(self, state: Any):
|
||||
self.__dict__.update(state)
|
||||
# In child process, self._agentops_server_manager will be None.
|
||||
logger.debug(f"Setting state for unpickled Trainer (PID {os.getpid()}). _agentops_server_manager is None.")
|
||||
|
||||
def init(self, *args: Any, **kwargs: Any):
|
||||
if self.agentops_managed and self._agentops_server_manager:
|
||||
self._agentops_server_manager.start()
|
||||
self._agentops_server_port_val = self._agentops_server_manager.get_port()
|
||||
if self._agentops_server_port_val is None:
|
||||
if (
|
||||
self._agentops_server_manager.server_process is not None
|
||||
and self._agentops_server_manager.server_process.is_alive()
|
||||
):
|
||||
raise RuntimeError("AgentOps server started but port is None. Check server manager logic.")
|
||||
elif (
|
||||
self._agentops_server_port_val is None and self._agentops_server_manager.server_process is None
|
||||
): # Server failed to start
|
||||
raise RuntimeError("AgentOps server manager indicates server is not running and port is None.")
|
||||
|
||||
def teardown(self):
|
||||
if self.agentops_managed:
|
||||
self._agentops_server_manager.stop()
|
||||
logger.info("AgentOps server stopped.")
|
||||
|
||||
def instrument(self, worker_id: int):
|
||||
instrument_all()
|
||||
|
||||
def uninstrument(self, worker_id: int):
|
||||
uninstrument_all()
|
||||
|
||||
def init_worker(self, worker_id: int):
|
||||
super().init_worker(worker_id)
|
||||
logger.info(f"[Worker {worker_id}] Setting up tracer...") # worker_id included in process name
|
||||
def _initialize_tracer_provider(self, worker_id: int):
|
||||
logger.info(f"[Worker {worker_id}] Setting up AgentOps tracer...") # worker_id included in process name
|
||||
|
||||
if self.instrument_managed:
|
||||
self.instrument(worker_id)
|
||||
logger.info(f"[Worker {worker_id}] Instrumentation applied.")
|
||||
|
||||
if self.agentops_managed:
|
||||
if self._agentops_server_port_val: # Use the stored, picklable port value
|
||||
base_url = f"http://localhost:{self._agentops_server_port_val}"
|
||||
env_vars_to_set = {
|
||||
"AGENTOPS_API_KEY": "dummy",
|
||||
"AGENTOPS_API_ENDPOINT": base_url,
|
||||
"AGENTOPS_APP_URL": f"{base_url}/notavailable",
|
||||
"AGENTOPS_EXPORTER_ENDPOINT": f"{base_url}/traces",
|
||||
}
|
||||
for key, value in env_vars_to_set.items():
|
||||
os.environ[key] = value
|
||||
logger.info(f"[Worker {worker_id}] Env var set: {key}={value}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"[Worker {worker_id}] AgentOps managed, but local server port is not available. Client may not connect as expected."
|
||||
)
|
||||
|
||||
os.environ.setdefault("AGENTOPS_API_KEY", "dummy")
|
||||
if not agentops.get_client().initialized:
|
||||
agentops.init() # type: ignore
|
||||
agentops.init(auto_start_session=False) # type: ignore
|
||||
logger.info(f"[Worker {worker_id}] AgentOps client initialized.")
|
||||
else:
|
||||
logger.warning(f"[Worker {worker_id}] AgentOps client was already initialized.")
|
||||
|
||||
self._lightning_span_processor = LightningSpanProcessor()
|
||||
|
||||
try:
|
||||
# new versions
|
||||
instance = agentops.sdk.core.tracer
|
||||
# TODO: The span processor cannot be deleted once added.
|
||||
# This might be a problem if the tracer is entered and exited multiple times.
|
||||
instance.provider.add_span_processor(self._lightning_span_processor) # type: ignore
|
||||
except AttributeError:
|
||||
# old versions
|
||||
instance = TracingCore.get_instance() # type: ignore
|
||||
instance._provider.add_span_processor(self._lightning_span_processor) # type: ignore
|
||||
# TODO: The span processor cannot be deleted once added.
|
||||
# This might be a problem if the tracer is entered and exited multiple times.
|
||||
self._get_tracer_provider().add_span_processor(self._lightning_span_processor) # type: ignore
|
||||
|
||||
def teardown_worker(self, worker_id: int) -> None:
|
||||
super().teardown_worker(worker_id)
|
||||
@@ -152,15 +94,15 @@ class AgentOpsTracer(BaseTracer):
|
||||
self.uninstrument(worker_id)
|
||||
logger.info(f"[Worker {worker_id}] Instrumentation removed.")
|
||||
|
||||
@contextmanager
|
||||
def trace_context(
|
||||
@asynccontextmanager
|
||||
async def trace_context(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
*,
|
||||
store: Optional[LightningStore] = None,
|
||||
rollout_id: Optional[str] = None,
|
||||
attempt_id: Optional[str] = None,
|
||||
) -> Iterator[LightningSpanProcessor]:
|
||||
) -> AsyncGenerator[trace_api.Tracer, None]:
|
||||
"""
|
||||
Starts a new tracing context. This should be used as a context manager.
|
||||
|
||||
@@ -171,31 +113,72 @@ class AgentOpsTracer(BaseTracer):
|
||||
attempt_id: Optional attempt ID to add the spans to.
|
||||
|
||||
Yields:
|
||||
The LightningSpanProcessor instance to collect spans.
|
||||
The OpenTelemetry tracer instance to collect spans.
|
||||
"""
|
||||
if store is not None:
|
||||
warnings.warn(
|
||||
"store is deprecated in favor of init_worker(). It will be removed in the future.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
else:
|
||||
store = self._store
|
||||
with self._trace_context_sync(name=name, store=store, rollout_id=rollout_id, attempt_id=attempt_id) as tracer:
|
||||
yield tracer
|
||||
|
||||
@contextmanager
|
||||
def _trace_context_sync(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
*,
|
||||
store: Optional[LightningStore] = None,
|
||||
rollout_id: Optional[str] = None,
|
||||
attempt_id: Optional[str] = None,
|
||||
) -> Iterator[trace_api.Tracer]:
|
||||
"""Implementation of `trace_context` for synchronous execution."""
|
||||
if not self._lightning_span_processor:
|
||||
raise RuntimeError("LightningSpanProcessor is not initialized. Call init_worker() first.")
|
||||
tracer_provider = self._get_tracer_provider()
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
if name is not None:
|
||||
kwargs["trace_name"] = name
|
||||
elif rollout_id is not None:
|
||||
kwargs["trace_name"] = rollout_id
|
||||
if store is not None and rollout_id is not None and attempt_id is not None:
|
||||
if store.capabilities.get("otlp_traces", False) is True:
|
||||
logger.debug(f"Tracing to LightningStore rollout_id={rollout_id}, attempt_id={attempt_id}")
|
||||
self._enable_native_otlp_exporter(store, rollout_id, attempt_id)
|
||||
else:
|
||||
self._disable_native_otlp_exporter()
|
||||
ctx = self._lightning_span_processor.with_context(store=store, rollout_id=rollout_id, attempt_id=attempt_id)
|
||||
with ctx as processor:
|
||||
yield processor
|
||||
with ctx:
|
||||
# AgentOps end_trace and start_trace must live inside the lightning span processor context.
|
||||
# Otherwise some traces might not be recorded.
|
||||
with self._agentops_trace_context(rollout_id, attempt_id, kwargs):
|
||||
yield trace_api.get_tracer(__name__, tracer_provider=tracer_provider)
|
||||
elif store is None and rollout_id is None and attempt_id is None:
|
||||
# TODO: Add tests to cover both paths
|
||||
self._disable_native_otlp_exporter()
|
||||
with self._lightning_span_processor:
|
||||
yield self._lightning_span_processor
|
||||
with self._agentops_trace_context(None, None, kwargs):
|
||||
yield trace_api.get_tracer(__name__, tracer_provider=tracer_provider)
|
||||
else:
|
||||
raise ValueError("store, rollout_id, and attempt_id must be either all provided or all None")
|
||||
|
||||
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.
|
||||
"""
|
||||
if not self._lightning_span_processor:
|
||||
raise RuntimeError("LightningSpanProcessor is not initialized. Call init_worker() first.")
|
||||
return self._lightning_span_processor.spans()
|
||||
@contextmanager
|
||||
def _agentops_trace_context(self, rollout_id: Optional[str], attempt_id: Optional[str], kwargs: dict[str, Any]):
|
||||
trace = agentops.start_trace(**kwargs)
|
||||
status = StatusCode.OK # type: ignore
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
# This will catch errors in user code.
|
||||
status = StatusCode.ERROR # type: ignore
|
||||
logger.error(f"Trace failed for rollout_id={rollout_id}, attempt_id={attempt_id}: {e}")
|
||||
raise # should reraise the error here so that runner can handle it
|
||||
finally:
|
||||
agentops.end_trace(trace, end_state=status) # type: ignore
|
||||
|
||||
def get_langchain_handler(self, tags: List[str] | None = None) -> LangchainCallbackHandler:
|
||||
"""
|
||||
@@ -223,191 +206,26 @@ class AgentOpsTracer(BaseTracer):
|
||||
|
||||
get_langchain_callback_handler = get_langchain_handler # alias
|
||||
|
||||
|
||||
async def heartbeat(name="exporter-loop", period=0.5):
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
last = time.perf_counter()
|
||||
while True:
|
||||
await asyncio.sleep(period)
|
||||
now = time.perf_counter()
|
||||
dt = now - last
|
||||
last = now
|
||||
if dt > period * 4: # e.g., >2s if period=0.5s
|
||||
print("!!!!!!! [%s] loop stall detected: slept %.3fs (expected %.3fs)" % (name, dt, period))
|
||||
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
# logging.basicConfig(level=logging.DEBUG)
|
||||
# asyncio.get_event_loop().set_debug(True)
|
||||
import time
|
||||
|
||||
|
||||
def debug_dump(loop):
|
||||
while True:
|
||||
def _get_tracer_provider(self) -> TracerProviderImpl:
|
||||
try:
|
||||
print("=== Pending tasks ===")
|
||||
for t in asyncio.all_tasks(loop):
|
||||
if not t.done():
|
||||
print(t, "awaiting", t.get_coro())
|
||||
t.print_stack()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(5)
|
||||
# new versions
|
||||
instance = agentops.sdk.core.tracer
|
||||
if instance.provider is None:
|
||||
raise RuntimeError("AgentOps TracerProvider is not initialized.")
|
||||
|
||||
if get_tracer_provider() is not instance.provider:
|
||||
logger.error(
|
||||
"Mismatch between global singleton TracerProvider and AgentOps TracerProvider. "
|
||||
"AgentOps might not work properly."
|
||||
)
|
||||
|
||||
class LightningSpanProcessor(SpanProcessor):
|
||||
def __init__(self):
|
||||
self._spans: List[ReadableSpan] = []
|
||||
if not isinstance(instance.provider, TracerProviderImpl): # type: ignore
|
||||
raise RuntimeError("Unsupported TracerProvider type for AgentOps instrumentation.")
|
||||
|
||||
# Store related context and states
|
||||
self._store: Optional[LightningStore] = None
|
||||
self._rollout_id: Optional[str] = None
|
||||
self._attempt_id: Optional[str] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# private asyncio loop running in a daemon thread
|
||||
self._loop_ready = threading.Event()
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._loop_thread = threading.Thread(target=self._loop_runner, name="otel-loop", daemon=True)
|
||||
self._loop_thread.start()
|
||||
self._loop_ready.wait() # loop is ready
|
||||
|
||||
def _loop_runner(self):
|
||||
loop = asyncio.new_event_loop()
|
||||
self._loop = loop
|
||||
self._loop.set_debug(True)
|
||||
asyncio.set_event_loop(loop)
|
||||
self._loop_ready.set()
|
||||
|
||||
thread = threading.Thread(target=debug_dump, args=(loop,), daemon=True)
|
||||
thread.start()
|
||||
# asyncio.create_task(heartbeat())
|
||||
loop.run_forever()
|
||||
loop.close()
|
||||
|
||||
def __enter__(self):
|
||||
self._last_trace = None
|
||||
self._spans = []
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
|
||||
self._store = None
|
||||
self._rollout_id = None
|
||||
self._attempt_id = None
|
||||
|
||||
def _await_in_loop(self, coro: Awaitable[Any], timeout: Optional[float] = None) -> Any:
|
||||
# submit to the dedicated loop and wait synchronously
|
||||
if self._loop is None:
|
||||
raise RuntimeError("Loop is not initialized. This should not happen.")
|
||||
|
||||
# If already on the exporter loop thread, schedule and return immediately.
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHY THIS CONDITIONAL EXISTS:
|
||||
# In rare cases, span.end() is triggered from a LangchainCallbackHandler.__del__
|
||||
# (or another finalizer) while the Python garbage collector is running on the
|
||||
# *same thread* that owns our exporter event loop ("otel-loop").
|
||||
#
|
||||
# When that happens, on_end() executes on the exporter loop thread itself.
|
||||
# If we were to call `asyncio.run_coroutine_threadsafe(...).result()` here,
|
||||
# it would deadlock immediately — because the loop cannot both wait on and run
|
||||
# the same coroutine. The Future stays pending forever and the loop stops
|
||||
# processing scheduled callbacks.
|
||||
#
|
||||
# To avoid that self-deadlock, we detect when on_end() runs on the exporter
|
||||
# loop thread. If so, we *schedule* the coroutine on the loop (fire-and-forget)
|
||||
# instead of blocking with .result().
|
||||
#
|
||||
# This situation can occur because Python calls __del__ in whatever thread
|
||||
# releases the last reference, which can easily be our loop thread if the
|
||||
# object is dereferenced during loop._run_once().
|
||||
# ---------------------------------------------------------------------------
|
||||
if threading.current_thread() is self._loop_thread:
|
||||
self._loop.call_soon_threadsafe(asyncio.create_task, coro) # type: ignore
|
||||
return None
|
||||
|
||||
fut = asyncio.run_coroutine_threadsafe(coro, self._loop) # type: ignore
|
||||
return fut.result(timeout=timeout) # raises on error # type: ignore
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self._loop:
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
self._loop_thread.join(timeout=5)
|
||||
self._loop = None
|
||||
|
||||
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
||||
return True
|
||||
|
||||
def spans(self) -> List[ReadableSpan]:
|
||||
"""
|
||||
Get the list of spans collected by this processor.
|
||||
This is useful for debugging and testing purposes.
|
||||
|
||||
Returns:
|
||||
List of ReadableSpan objects collected during tracing.
|
||||
"""
|
||||
return self._spans
|
||||
|
||||
def with_context(self, store: LightningStore, rollout_id: str, attempt_id: str):
|
||||
# simple context manager without nesting into asyncio
|
||||
class _Ctx:
|
||||
def __enter__(_): # type: ignore
|
||||
with self._lock:
|
||||
self._store, self._rollout_id, self._attempt_id = store, rollout_id, attempt_id
|
||||
self._last_trace = None
|
||||
self._spans = []
|
||||
return self
|
||||
|
||||
def __exit__(_, exc_type, exc, tb): # type: ignore
|
||||
with self._lock:
|
||||
self._store = self._rollout_id = self._attempt_id = None
|
||||
|
||||
return _Ctx()
|
||||
|
||||
def on_end(self, span: ReadableSpan) -> None:
|
||||
"""
|
||||
Process a span when it ends.
|
||||
|
||||
Args:
|
||||
span: The span that has ended.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
# print("ON_END")
|
||||
# print(traceback.format_stack())
|
||||
# Skip if span is not sampled
|
||||
if not span.context or not span.context.trace_flags.sampled:
|
||||
return
|
||||
|
||||
if self._store and self._rollout_id and self._attempt_id:
|
||||
try:
|
||||
# Submit add_otel_span to the event loop and wait for it to complete
|
||||
print("!!! before,")
|
||||
print("Ready callbacks:", self._loop._ready)
|
||||
print("Scheduled callbacks:", len(self._loop._scheduled))
|
||||
if self._loop._scheduled:
|
||||
print("First in the queue:", self._loop._scheduled[0])
|
||||
print("..... Current thread: ", threading.current_thread())
|
||||
print("..... Loop thread: ", self._loop_thread)
|
||||
if self._loop_thread.ident == threading.current_thread().ident:
|
||||
traceback.print_stack()
|
||||
print("Span content: ", span.attributes)
|
||||
from opentelemetry.instrumentation.utils import suppress_instrumentation
|
||||
|
||||
with suppress_instrumentation():
|
||||
self._await_in_loop(
|
||||
self._store.add_otel_span(self._rollout_id, self._attempt_id, span),
|
||||
timeout=30.0,
|
||||
)
|
||||
print("!!! after,")
|
||||
print("All tasks")
|
||||
print("Ready callbacks:", self._loop._ready)
|
||||
print("Scheduled callbacks:", self._loop._scheduled)
|
||||
except Exception:
|
||||
# log; on_end MUST NOT raise
|
||||
logger.exception(f"Error adding span to store: {span.name}")
|
||||
|
||||
self._spans.append(span)
|
||||
self._tracer_provider = instance.provider
|
||||
return self._tracer_provider
|
||||
except AttributeError:
|
||||
# old versions
|
||||
instance = TracingCore.get_instance() # type: ignore
|
||||
self._tracer_provider = instance._provider # type: ignore
|
||||
return self._tracer_provider # type: ignore
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterator, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, AsyncContextManager, Awaitable, Callable, ContextManager, List, Optional
|
||||
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
|
||||
@@ -12,12 +12,12 @@ from agentlightning.store.base import LightningStore
|
||||
from agentlightning.types import ParallelWorkerBase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain.callbacks.base import BaseCallbackHandler
|
||||
from langchain_core.callbacks.base import BaseCallbackHandler # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseTracer(ParallelWorkerBase):
|
||||
class Tracer(ParallelWorkerBase):
|
||||
"""
|
||||
An abstract base class for tracers.
|
||||
|
||||
@@ -26,7 +26,7 @@ class BaseTracer(ParallelWorkerBase):
|
||||
designed to be backend-agnostic, allowing for different implementations
|
||||
(e.g., for AgentOps, OpenTelemetry, Docker, etc.).
|
||||
|
||||
The primary interaction pattern is through the `trace_context`
|
||||
The primary interaction pattern is through the [`trace_context`][agentlightning.Tracer.trace_context]
|
||||
context manager, which ensures that traces are properly started and captured,
|
||||
even in the case of exceptions.
|
||||
|
||||
@@ -36,9 +36,9 @@ class BaseTracer(ParallelWorkerBase):
|
||||
tracer = YourTracerImplementation()
|
||||
|
||||
try:
|
||||
with tracer.trace_context(name="my_traced_task"):
|
||||
async with tracer.trace_context(name="my_traced_task"):
|
||||
# ... code to be traced ...
|
||||
run_my_agent_logic()
|
||||
await run_my_agent_logic()
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {e}")
|
||||
|
||||
@@ -52,7 +52,18 @@ class BaseTracer(ParallelWorkerBase):
|
||||
```
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
_store: Optional[LightningStore] = None
|
||||
|
||||
def init_worker(self, worker_id: int, store: Optional[LightningStore] = None) -> None:
|
||||
"""Initialize the tracer for a worker.
|
||||
|
||||
Args:
|
||||
worker_id: The ID of the worker.
|
||||
store: The store to add the spans to. If it's provided, traces will be added to the store when tracing.
|
||||
"""
|
||||
super().init_worker(worker_id)
|
||||
self._store = store
|
||||
|
||||
def trace_context(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
@@ -60,25 +71,33 @@ class BaseTracer(ParallelWorkerBase):
|
||||
store: Optional[LightningStore] = None,
|
||||
rollout_id: Optional[str] = None,
|
||||
attempt_id: Optional[str] = None,
|
||||
) -> Iterator[Any]:
|
||||
) -> AsyncContextManager[Any]:
|
||||
"""
|
||||
Starts a new tracing context. This should be used as a context manager.
|
||||
|
||||
The implementation should handle the setup and teardown of the tracing
|
||||
for the enclosed code block. It must ensure that any spans generated
|
||||
within the `with` block are collected and made available via
|
||||
`get_last_trace`.
|
||||
|
||||
If a store is provided, the spans will be added to the store when tracing.
|
||||
[`get_last_trace`][agentlightning.Tracer.get_last_trace].
|
||||
|
||||
Args:
|
||||
name: The name for the root span of this trace context.
|
||||
store: The store to add the spans to.
|
||||
store: The store to add the spans to. Deprecated in favor of passing store to init_worker().
|
||||
rollout_id: The rollout ID to add the spans to.
|
||||
attempt_id: The attempt ID to add the spans to.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _trace_context_sync(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
*,
|
||||
rollout_id: Optional[str] = None,
|
||||
attempt_id: Optional[str] = None,
|
||||
) -> ContextManager[Any]:
|
||||
"""Internal API for CI backward compatibility."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_last_trace(self) -> List[ReadableSpan]:
|
||||
"""
|
||||
Retrieves the raw list of captured spans from the most recent trace.
|
||||
@@ -92,6 +111,8 @@ class BaseTracer(ParallelWorkerBase):
|
||||
"""
|
||||
A convenience wrapper to trace the execution of a single synchronous function.
|
||||
|
||||
Deprecated in favor of customizing Runners.
|
||||
|
||||
Args:
|
||||
func: The synchronous function to execute and trace.
|
||||
*args: Positional arguments to pass to the function.
|
||||
@@ -100,13 +121,15 @@ class BaseTracer(ParallelWorkerBase):
|
||||
Returns:
|
||||
The return value of the function.
|
||||
"""
|
||||
with self.trace_context(name=func.__name__):
|
||||
with self._trace_context_sync(name=func.__name__):
|
||||
return 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.
|
||||
|
||||
Deprecated in favor of customizing Runners.
|
||||
|
||||
Args:
|
||||
func: The asynchronous function to execute and trace.
|
||||
*args: Positional arguments to pass to the function.
|
||||
@@ -115,13 +138,40 @@ class BaseTracer(ParallelWorkerBase):
|
||||
Returns:
|
||||
The return value of the function.
|
||||
"""
|
||||
with self.trace_context(name=func.__name__):
|
||||
async with self.trace_context(name=func.__name__):
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
def get_langchain_handler(self) -> Optional[BaseCallbackHandler]:
|
||||
def get_langchain_handler(self) -> Optional[BaseCallbackHandler]: # type: ignore
|
||||
"""Get a handler to install in langchain agent callback.
|
||||
|
||||
Agents are expected to use this handler in their agents to enable tracing.
|
||||
"""
|
||||
logger.warning(f"{self.__class__.__name__} does not provide a LangChain callback handler.")
|
||||
return None
|
||||
|
||||
@contextmanager
|
||||
def lifespan(self, store: Optional[LightningStore] = None):
|
||||
"""A context manager to manage the lifespan of the tracer.
|
||||
|
||||
This can be used to set up and tear down any necessary resources
|
||||
for the tracer, useful for debugging purposes.
|
||||
|
||||
Args:
|
||||
store: The store to add the spans to. If it's provided, traces will be added to the store when tracing.
|
||||
"""
|
||||
has_init = False
|
||||
has_init_worker = False
|
||||
try:
|
||||
self.init()
|
||||
has_init = True
|
||||
|
||||
self.init_worker(0, store)
|
||||
has_init_worker = True
|
||||
|
||||
yield
|
||||
|
||||
finally:
|
||||
if has_init_worker:
|
||||
self.teardown_worker(0)
|
||||
if has_init:
|
||||
self.teardown()
|
||||
|
||||
@@ -5,8 +5,8 @@ import logging
|
||||
import multiprocessing
|
||||
import queue
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterator, List, Optional, Tuple
|
||||
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
|
||||
@@ -19,12 +19,14 @@ from opentelemetry.trace.span import (
|
||||
TraceState,
|
||||
)
|
||||
|
||||
from .base import BaseTracer
|
||||
from agentlightning.store import LightningStore
|
||||
|
||||
from .base import Tracer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HttpTracer(BaseTracer):
|
||||
class HttpTracer(Tracer):
|
||||
"""
|
||||
A tracer implementation that captures HTTP requests using httpdbg.
|
||||
|
||||
@@ -68,18 +70,30 @@ class HttpTracer(BaseTracer):
|
||||
self.subprocess_mode = subprocess_mode
|
||||
self.subprocess_timeout = subprocess_timeout
|
||||
|
||||
def init_worker(self, worker_id: int) -> None:
|
||||
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)
|
||||
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(self, name: Optional[str] = None, **kwargs: Any) -> Iterator[HTTPRecords]:
|
||||
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.
|
||||
|
||||
|
||||
+279
-23
@@ -2,22 +2,32 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator, List, Optional
|
||||
import threading
|
||||
import warnings
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncGenerator, Awaitable, List, Optional
|
||||
|
||||
import opentelemetry.trace as trace_api
|
||||
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
|
||||
from agentops.sdk.core import BatchSpanProcessor
|
||||
from opentelemetry.instrumentation.utils import suppress_instrumentation
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace import TracerProvider as TracerProviderImpl
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
|
||||
from agentlightning.store.base import LightningStore
|
||||
from agentlightning.types.tracer import SpanNames
|
||||
from agentlightning.utils.otlp import LightningStoreOTLPExporter
|
||||
|
||||
from .agentops import LightningSpanProcessor # FIXME: This import should be from otel to agentops
|
||||
from .base import BaseTracer
|
||||
from .base import Tracer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OtelTracer(BaseTracer):
|
||||
class OtelTracer(Tracer):
|
||||
"""Tracer that provides a basic OpenTelemetry tracer provider.
|
||||
|
||||
You should be able to collect agent-lightning signals like rewards with this tracer,
|
||||
@@ -29,35 +39,45 @@ class OtelTracer(BaseTracer):
|
||||
# This provider is only initialized when the worker is initialized.
|
||||
self._tracer_provider: Optional[TracerProvider] = None
|
||||
self._lightning_span_processor: Optional[LightningSpanProcessor] = None
|
||||
self._simple_span_processor: Optional[SimpleSpanProcessor] = None
|
||||
self._otlp_span_exporter: Optional[LightningStoreOTLPExporter] = None
|
||||
self._initialized: bool = False
|
||||
|
||||
def init_worker(self, worker_id: int):
|
||||
super().init_worker(worker_id)
|
||||
def init_worker(self, worker_id: int, store: Optional[LightningStore] = None):
|
||||
super().init_worker(worker_id, store)
|
||||
self._initialize_tracer_provider(worker_id)
|
||||
|
||||
def _initialize_tracer_provider(self, worker_id: int):
|
||||
logger.info(f"[Worker {worker_id}] Setting up OpenTelemetry tracer...")
|
||||
|
||||
if self._initialized:
|
||||
logger.error("Tracer provider is already initialized. OpenTelemetry may not work as expected.")
|
||||
|
||||
tracer_provider = TracerProvider()
|
||||
trace_api.set_tracer_provider(tracer_provider)
|
||||
self._tracer_provider = TracerProvider()
|
||||
trace_api.set_tracer_provider(self._tracer_provider)
|
||||
self._lightning_span_processor = LightningSpanProcessor()
|
||||
tracer_provider.add_span_processor(self._lightning_span_processor)
|
||||
self._tracer_provider.add_span_processor(self._lightning_span_processor)
|
||||
self._otlp_span_exporter = LightningStoreOTLPExporter()
|
||||
self._simple_span_processor = SimpleSpanProcessor(self._otlp_span_exporter)
|
||||
self._tracer_provider.add_span_processor(self._simple_span_processor)
|
||||
self._initialized = True
|
||||
|
||||
logger.info(f"[Worker {worker_id}] OpenTelemetry tracer provider initialized.")
|
||||
|
||||
def teardown_worker(self, worker_id: int):
|
||||
super().teardown_worker(worker_id)
|
||||
logger.info(f"[Worker {worker_id}] Tearing down OpenTelemetry tracer...")
|
||||
self._tracer_provider = None
|
||||
|
||||
@contextmanager
|
||||
def trace_context(
|
||||
@asynccontextmanager
|
||||
async def trace_context(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
*,
|
||||
store: Optional[LightningStore] = None,
|
||||
rollout_id: Optional[str] = None,
|
||||
attempt_id: Optional[str] = None,
|
||||
) -> Iterator[LightningSpanProcessor]:
|
||||
) -> AsyncGenerator[trace_api.Tracer, None]:
|
||||
"""
|
||||
Starts a new tracing context. This should be used as a context manager.
|
||||
|
||||
@@ -68,20 +88,37 @@ class OtelTracer(BaseTracer):
|
||||
attempt_id: Optional attempt ID to add the spans to.
|
||||
|
||||
Yields:
|
||||
The LightningSpanProcessor instance to collect spans.
|
||||
The OpenTelemetry tracer instance to collect spans.
|
||||
"""
|
||||
if not self._lightning_span_processor:
|
||||
raise RuntimeError("LightningSpanProcessor is not initialized. Call init_worker() first.")
|
||||
|
||||
if store is not None and rollout_id is not None and attempt_id is not None:
|
||||
ctx = self._lightning_span_processor.with_context(store=store, rollout_id=rollout_id, attempt_id=attempt_id)
|
||||
with ctx as processor:
|
||||
yield processor
|
||||
elif store is None and rollout_id is None and attempt_id is None:
|
||||
with self._lightning_span_processor:
|
||||
yield self._lightning_span_processor
|
||||
if store is not None:
|
||||
warnings.warn(
|
||||
"store is deprecated in favor of init_worker(). It will be removed in the future.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
else:
|
||||
raise ValueError("store, rollout_id, and attempt_id must be either all provided or all None")
|
||||
store = self._store
|
||||
|
||||
if rollout_id is not None and attempt_id is not None:
|
||||
if store is None:
|
||||
raise ValueError("store is required to be initialized when rollout_id and attempt_id are provided")
|
||||
if store.capabilities.get("otlp_traces", False) is True:
|
||||
logger.debug(f"Tracing to LightningStore rollout_id={rollout_id}, attempt_id={attempt_id}")
|
||||
self._enable_native_otlp_exporter(store, rollout_id, attempt_id)
|
||||
else:
|
||||
self._disable_native_otlp_exporter()
|
||||
ctx = self._lightning_span_processor.with_context(store=store, rollout_id=rollout_id, attempt_id=attempt_id)
|
||||
with ctx:
|
||||
yield trace_api.get_tracer(__name__, tracer_provider=self._tracer_provider)
|
||||
elif rollout_id is None and attempt_id is None:
|
||||
self._disable_native_otlp_exporter()
|
||||
with self._lightning_span_processor:
|
||||
yield trace_api.get_tracer(__name__, tracer_provider=self._tracer_provider)
|
||||
else:
|
||||
raise ValueError("rollout_id and attempt_id must be either all provided or all None")
|
||||
|
||||
def get_last_trace(self) -> List[ReadableSpan]:
|
||||
"""
|
||||
@@ -93,3 +130,222 @@ class OtelTracer(BaseTracer):
|
||||
if not self._lightning_span_processor:
|
||||
raise RuntimeError("LightningSpanProcessor is not initialized. Call init_worker() first.")
|
||||
return self._lightning_span_processor.spans()
|
||||
|
||||
def _get_tracer_provider(self) -> TracerProviderImpl:
|
||||
if self._tracer_provider is None:
|
||||
raise RuntimeError("TracerProvider is not initialized. Call init_worker() first.")
|
||||
return self._tracer_provider
|
||||
|
||||
def _enable_native_otlp_exporter(self, store: LightningStore, rollout_id: str, attempt_id: str):
|
||||
tracer_provider = self._get_tracer_provider()
|
||||
active_span_processor = tracer_provider._active_span_processor # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Override the resources so that the server knows where the request comes from.
|
||||
tracer_provider._resource = tracer_provider._resource.merge( # pyright: ignore[reportPrivateUsage]
|
||||
Resource.create(
|
||||
{
|
||||
SpanNames.ROLLOUT_ID: rollout_id,
|
||||
SpanNames.ATTEMPT_ID: attempt_id,
|
||||
}
|
||||
)
|
||||
)
|
||||
instrumented = False
|
||||
candidates: List[str] = []
|
||||
for processor in active_span_processor._span_processors: # pyright: ignore[reportPrivateUsage]
|
||||
if isinstance(processor, LightningSpanProcessor):
|
||||
# We don't need the LightningSpanProcessor any more.
|
||||
logger.debug("LightningSpanProcessor already present in TracerProvider, disabling it.")
|
||||
processor.disable_store_submission = True
|
||||
elif isinstance(processor, (SimpleSpanProcessor, BatchSpanProcessor)):
|
||||
# Instead, we rely on the OTLPSpanExporter to send spans to the store.
|
||||
if isinstance(processor.span_exporter, LightningStoreOTLPExporter):
|
||||
processor.span_exporter.enable_store_otlp(store.otlp_traces_endpoint(), rollout_id, attempt_id)
|
||||
logger.debug(f"Set LightningStoreOTLPExporter endpoint to {store.otlp_traces_endpoint()}")
|
||||
instrumented = True
|
||||
else:
|
||||
candidates.append(
|
||||
f"{processor.__class__.__name__} with {processor.span_exporter.__class__.__name__}"
|
||||
)
|
||||
else:
|
||||
candidates.append(f"{processor.__class__.__name__}")
|
||||
|
||||
if not instrumented:
|
||||
raise RuntimeError(
|
||||
"Failed to enable native OTLP exporter: no BatchSpanProcessor or SimpleSpanProcessor with "
|
||||
"LightningStoreOTLPExporter found in TracerProvider. Please try using a non-OTLP store."
|
||||
"Candidates are: " + ", ".join(candidates)
|
||||
)
|
||||
|
||||
def _disable_native_otlp_exporter(self):
|
||||
tracer_provider = self._get_tracer_provider()
|
||||
active_span_processor = tracer_provider._active_span_processor # pyright: ignore[reportPrivateUsage]
|
||||
tracer_provider._resource = tracer_provider._resource.merge( # pyright: ignore[reportPrivateUsage]
|
||||
Resource.create(
|
||||
{
|
||||
SpanNames.ROLLOUT_ID: "",
|
||||
SpanNames.ATTEMPT_ID: "",
|
||||
}
|
||||
)
|
||||
) # reset resource
|
||||
for processor in active_span_processor._span_processors: # pyright: ignore[reportPrivateUsage]
|
||||
if isinstance(processor, LightningSpanProcessor):
|
||||
# We will be in need of the LightningSpanProcessor again.
|
||||
logger.debug("Enabling LightningSpanProcessor in TracerProvider.")
|
||||
processor.disable_store_submission = False
|
||||
|
||||
|
||||
class LightningSpanProcessor(SpanProcessor):
|
||||
"""Span processor that subclasses OpenTelemetry's `SpanProcessor` and adds support to dump traces
|
||||
to a [`LightningStore`][agentlightning.LightningStore].
|
||||
|
||||
It serves two purposes:
|
||||
|
||||
1. Records all the spans in a local buffer.
|
||||
2. Submits the spans to the event loop to be added to the store.
|
||||
"""
|
||||
|
||||
def __init__(self, disable_store_submission: bool = False):
|
||||
self._disable_store_submission: bool = disable_store_submission
|
||||
self._spans: List[ReadableSpan] = []
|
||||
|
||||
# Store related context and states
|
||||
self._store: Optional[LightningStore] = None
|
||||
self._rollout_id: Optional[str] = None
|
||||
self._attempt_id: Optional[str] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# private asyncio loop running in a daemon thread
|
||||
self._loop_ready = threading.Event()
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._loop_thread: Optional[threading.Thread] = None
|
||||
|
||||
@property
|
||||
def disable_store_submission(self) -> bool:
|
||||
"""Whether to disable submitting spans to the store."""
|
||||
return self._disable_store_submission
|
||||
|
||||
@disable_store_submission.setter
|
||||
def disable_store_submission(self, value: bool) -> None:
|
||||
self._disable_store_submission = value
|
||||
|
||||
def _ensure_loop(self) -> None:
|
||||
if self._loop_thread is None or self._loop is None:
|
||||
self._loop_ready.clear()
|
||||
self._loop_thread = threading.Thread(target=self._loop_runner, name="otel-loop", daemon=True)
|
||||
self._loop_thread.start()
|
||||
self._loop_ready.wait() # loop is ready
|
||||
|
||||
def _loop_runner(self):
|
||||
loop = asyncio.new_event_loop()
|
||||
self._loop = loop
|
||||
asyncio.set_event_loop(loop)
|
||||
self._loop_ready.set()
|
||||
loop.run_forever()
|
||||
loop.close()
|
||||
|
||||
def __enter__(self):
|
||||
self._last_trace = None
|
||||
self._spans = []
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
|
||||
self._store = None
|
||||
self._rollout_id = None
|
||||
self._attempt_id = None
|
||||
|
||||
def _await_in_loop(self, coro: Awaitable[Any], timeout: Optional[float] = None) -> Any:
|
||||
# submit to the dedicated loop and wait synchronously
|
||||
self._ensure_loop()
|
||||
if self._loop is None:
|
||||
raise RuntimeError("Loop is not initialized. This should not happen.")
|
||||
|
||||
# If already on the exporter loop thread, schedule and return immediately.
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHY THIS CONDITIONAL EXISTS:
|
||||
# In rare cases, span.end() is triggered from a LangchainCallbackHandler.__del__
|
||||
# (or another finalizer) while the Python garbage collector is running on the
|
||||
# *same thread* that owns our exporter event loop ("otel-loop").
|
||||
#
|
||||
# When that happens, on_end() executes on the exporter loop thread itself.
|
||||
# If we were to call `asyncio.run_coroutine_threadsafe(...).result()` here,
|
||||
# it would deadlock immediately — because the loop cannot both wait on and run
|
||||
# the same coroutine. The Future stays pending forever and the loop stops
|
||||
# processing scheduled callbacks.
|
||||
#
|
||||
# To avoid that self-deadlock, we detect when on_end() runs on the exporter
|
||||
# loop thread. If so, we *schedule* the coroutine on the loop (fire-and-forget)
|
||||
# instead of blocking with .result().
|
||||
#
|
||||
# This situation can occur because Python calls __del__ in whatever thread
|
||||
# releases the last reference, which can easily be our loop thread if the
|
||||
# object is dereferenced during loop._run_once().
|
||||
# ---------------------------------------------------------------------------
|
||||
if threading.current_thread() is self._loop_thread:
|
||||
self._loop.call_soon_threadsafe(asyncio.create_task, coro) # type: ignore
|
||||
return None
|
||||
|
||||
fut = asyncio.run_coroutine_threadsafe(coro, self._loop) # type: ignore
|
||||
return fut.result(timeout=timeout) # raises on error # type: ignore
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self._loop:
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
self._loop = None
|
||||
if self._loop_thread:
|
||||
self._loop_thread.join(timeout=5)
|
||||
|
||||
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
||||
return True
|
||||
|
||||
def spans(self) -> List[ReadableSpan]:
|
||||
"""
|
||||
Get the list of spans collected by this processor.
|
||||
This is useful for debugging and testing purposes.
|
||||
|
||||
Returns:
|
||||
List of ReadableSpan objects collected during tracing.
|
||||
"""
|
||||
return self._spans
|
||||
|
||||
def with_context(self, store: LightningStore, rollout_id: str, attempt_id: str):
|
||||
# simple context manager without nesting into asyncio
|
||||
class _Ctx:
|
||||
def __enter__(_): # type: ignore
|
||||
# Use _ instead of self to avoid shadowing the instance method.
|
||||
with self._lock:
|
||||
self._store, self._rollout_id, self._attempt_id = store, rollout_id, attempt_id
|
||||
self._last_trace = None
|
||||
self._spans = []
|
||||
return self
|
||||
|
||||
def __exit__(_, exc_type, exc, tb): # type: ignore
|
||||
with self._lock:
|
||||
self._store = self._rollout_id = self._attempt_id = None
|
||||
|
||||
return _Ctx()
|
||||
|
||||
def on_end(self, span: ReadableSpan) -> None:
|
||||
"""
|
||||
Process a span when it ends.
|
||||
|
||||
Args:
|
||||
span: The span that has ended.
|
||||
"""
|
||||
# Skip if span is not sampled
|
||||
if not span.context or not span.context.trace_flags.sampled:
|
||||
return
|
||||
|
||||
if not self._disable_store_submission and self._store and self._rollout_id and self._attempt_id:
|
||||
try:
|
||||
# Submit add_otel_span to the event loop and wait for it to complete
|
||||
with suppress_instrumentation():
|
||||
self._ensure_loop()
|
||||
self._await_in_loop(
|
||||
self._store.add_otel_span(self._rollout_id, self._attempt_id, span),
|
||||
timeout=60.0,
|
||||
)
|
||||
except Exception:
|
||||
# log; on_end MUST NOT raise
|
||||
logger.exception(f"Error adding span to store: {span.name}")
|
||||
|
||||
self._spans.append(span)
|
||||
|
||||
@@ -9,11 +9,11 @@ import warnings
|
||||
from typing import Any, List, Optional, TypeVar, Union
|
||||
|
||||
from agentlightning.adapter import TraceAdapter, TracerTraceToTriplet
|
||||
from agentlightning.algorithm import BaseAlgorithm
|
||||
from agentlightning.algorithm import Algorithm
|
||||
from agentlightning.client import AgentLightningClient
|
||||
from agentlightning.litagent import LitAgent
|
||||
from agentlightning.runner import LegacyAgentRunner
|
||||
from agentlightning.tracer.base import BaseTracer
|
||||
from agentlightning.tracer.base import Tracer
|
||||
from agentlightning.types import Dataset, ParallelWorkerBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,8 +31,8 @@ class TrainerLegacy(ParallelWorkerBase):
|
||||
It won't be used in practice.
|
||||
"""
|
||||
self._dev = kwargs.pop("dev", False)
|
||||
self.algorithm: Optional[BaseAlgorithm] = kwargs.pop("algorithm", None)
|
||||
self.tracer: BaseTracer = kwargs.pop("tracer", None)
|
||||
self.algorithm: Optional[Algorithm] = kwargs.pop("algorithm", None)
|
||||
self.tracer: Tracer = kwargs.pop("tracer", None)
|
||||
self.n_workers: int = kwargs.pop("n_workers", None)
|
||||
self.max_tasks: Optional[int] = kwargs.pop("max_tasks", None)
|
||||
self.daemon: bool = kwargs.pop("daemon", True)
|
||||
|
||||
@@ -7,18 +7,18 @@ import warnings
|
||||
from typing import Any, Callable, Dict, Optional, Sequence, TypeVar, Union
|
||||
|
||||
from agentlightning.adapter import TraceAdapter, TracerTraceToTriplet
|
||||
from agentlightning.algorithm import BaseAlgorithm, Baseline, FastAlgorithm
|
||||
from agentlightning.algorithm import Algorithm, Baseline, FastAlgorithm
|
||||
from agentlightning.client import AgentLightningClient
|
||||
from agentlightning.execution.base import ExecutionStrategy
|
||||
from agentlightning.execution.client_server import ClientServerExecutionStrategy
|
||||
from agentlightning.execution.events import ExecutionEvent
|
||||
from agentlightning.litagent import LitAgent
|
||||
from agentlightning.llm_proxy import LLMProxy
|
||||
from agentlightning.runner import BaseRunner, LitAgentRunner
|
||||
from agentlightning.runner import LitAgentRunner, Runner
|
||||
from agentlightning.store.base import LightningStore
|
||||
from agentlightning.store.memory import InMemoryLightningStore
|
||||
from agentlightning.tracer.agentops import AgentOpsTracer
|
||||
from agentlightning.tracer.base import BaseTracer
|
||||
from agentlightning.tracer.base import Tracer
|
||||
from agentlightning.types import Dataset, Hook, NamedResources
|
||||
|
||||
from .init_utils import build_component, instantiate_component
|
||||
@@ -34,44 +34,89 @@ ComponentSpec = Union[T, type[T], Callable[[], T], str, Dict[str, Any], None]
|
||||
|
||||
|
||||
class Trainer(TrainerLegacy):
|
||||
"""Orchestrates the distributed execution of agent rollouts.
|
||||
"""High-level orchestration layer that wires Algorithm <-> Runner <-> Store.
|
||||
|
||||
The Trainer is responsible for launching one or more worker processes
|
||||
that run the agent's execution loop. It manages multiprocessing,
|
||||
handles graceful shutdown, and serves as the main entry point for
|
||||
running a client-side agent fleet.
|
||||
A [`Trainer`][agentlightning.Trainer] packages the moving parts of Agent-Lightning's
|
||||
training loop into a single entry point:
|
||||
|
||||
Attributes:
|
||||
algorithm: An instance of `BaseAlgorithm` to use for training.
|
||||
store: An instance of `LightningStore` to use for storing tasks and traces.
|
||||
runner: An instance of `BaseRunner` to use for running the agent.
|
||||
initial_resources: An instance of `Resources` to use for bootstrapping the fit/dev process.
|
||||
The resources will be handed over to the algorithm.
|
||||
Note that not all algorithms support seeding resources.
|
||||
n_runners: Number of agent runners to run in parallel.
|
||||
max_rollouts: Maximum number of rollouts to process per runner. If None,
|
||||
workers run until no more rollouts are available.
|
||||
strategy: An instance of `ExecutionStrategy` to use for spawning the algorithm and runners.
|
||||
tracer: A tracer instance, or a string pointing to the class full name or a dictionary with a 'type' key
|
||||
that specifies the class full name and other initialization parameters.
|
||||
If None, a default `AgentOpsTracer` will be created with the current settings.
|
||||
hooks: A sequence of `Hook` instances to be called at various lifecycle stages (e.g., on_trace_start,
|
||||
on_trace_end, on_rollout_start, on_rollout_end).
|
||||
adapter: An instance of `TracerTraceToTriplet` to export data consumble by algorithms from traces.
|
||||
llm_proxy: An instance of `LLMProxy` to use for intercepting the LLM calls.
|
||||
If not provided, algorithm will create one on its own.
|
||||
n_workers: Number of agent workers to run in parallel. Deprecated in favor of `n_runners`.
|
||||
max_tasks: Maximum number of tasks to process per runner. Deprecated in favor of `max_rollouts`.
|
||||
daemon: Whether worker processes should be daemons. Daemon processes
|
||||
are terminated automatically when the main process exits. Deprecated.
|
||||
Only have effect with `fit_v0`.
|
||||
triplet_exporter: An instance of `TracerTraceToTriplet` to export triplets from traces,
|
||||
or a dictionary with the initialization parameters for the exporter.
|
||||
Deprecated. Use `adapter` instead.
|
||||
dev: If True, rollouts are run against the dev endpoint provided in `fit`.
|
||||
Deprecated in favor of `dev()` method.
|
||||
* **Algorithm lifecycle:** Instantiates or accepts an [`Algorithm`][agentlightning.Algorithm],
|
||||
attaches the current [`LightningStore`][agentlightning.LightningStore], adapter, and
|
||||
initial resources, then executes the algorithm role inside the configured execution strategy.
|
||||
* **Runner fleet:** Spawns one or more [`Runner`][agentlightning.Runner] instances (defaulting
|
||||
to [`LitAgentRunner`][agentlightning.LitAgentRunner]) that hydrate a [`LitAgent`][agentlightning.LitAgent],
|
||||
claim rollouts, stream spans, and respect graceful termination signals from the execution strategy.
|
||||
* **Execution strategy:** Delegates process management to an
|
||||
[`ExecutionStrategy`][agentlightning.ExecutionStrategy] (shared memory, client/server, etc.),
|
||||
so advanced users can swap orchestration backends without changing trainer code.
|
||||
* **Telemetry plumbing:** Ensures tracers, adapters, and optional [`LLMProxy`][agentlightning.LLMProxy]
|
||||
are wired into both algorithm and runners so telemetry flows back into the store.
|
||||
|
||||
The trainer exposes two convenience entry points:
|
||||
[`fit()`][agentlightning.Trainer.fit] for full training and
|
||||
[`dev()`][agentlightning.Trainer.dev] for fast, reproducible dry-runs. See the
|
||||
[Train the First Agent](../how-to/train-first-agent.md) and
|
||||
[Write the First Algorithm](../how-to/write-first-algorithm.md) tutorials for the broader context.
|
||||
"""
|
||||
|
||||
algorithm: Optional[Algorithm]
|
||||
"""An instance of [`Algorithm`][agentlightning.Algorithm] to use for training."""
|
||||
|
||||
store: LightningStore
|
||||
"""An instance of [`LightningStore`][agentlightning.LightningStore] to use for storing tasks and traces."""
|
||||
|
||||
runner: Runner[Any]
|
||||
"""An instance of [`Runner`][agentlightning.Runner] to use for running the agent."""
|
||||
|
||||
initial_resources: Optional[NamedResources]
|
||||
"""An instance of [`NamedResources`][agentlightning.NamedResources] to use for bootstrapping the fit/dev process.
|
||||
|
||||
The resources will be handed over to the algorithm. Note that not all algorithms support seeding resources.
|
||||
"""
|
||||
|
||||
n_runners: int
|
||||
"""Number of agent runners to run in parallel."""
|
||||
|
||||
max_rollouts: Optional[int]
|
||||
"""Maximum number of rollouts to process per runner. If None, workers run until no more rollouts are available."""
|
||||
|
||||
strategy: ExecutionStrategy
|
||||
"""An instance of [`ExecutionStrategy`][agentlightning.ExecutionStrategy] to use for spawning the algorithm and runners."""
|
||||
|
||||
tracer: Tracer
|
||||
"""A tracer instance, or a string pointing to the class full name or a dictionary with a 'type' key
|
||||
that specifies the class full name and other initialization parameters.
|
||||
If None, a default [`AgentOpsTracer`][agentlightning.AgentOpsTracer] will be created with the current settings."""
|
||||
|
||||
hooks: Sequence[Hook]
|
||||
"""A sequence of [`Hook`][agentlightning.Hook] instances to be called at various lifecycle stages (e.g., `on_trace_start`,
|
||||
`on_trace_end`, `on_rollout_start`, `on_rollout_end`)."""
|
||||
|
||||
adapter: TraceAdapter[Any]
|
||||
"""An instance of [`TraceAdapter`][agentlightning.TraceAdapter] to export data consumble by algorithms from traces."""
|
||||
|
||||
llm_proxy: Optional[LLMProxy]
|
||||
"""An instance of [`LLMProxy`][agentlightning.LLMProxy] to use for intercepting the LLM calls.
|
||||
If not provided, algorithm may create one on its own."""
|
||||
|
||||
n_workers: int
|
||||
"""Number of agent workers to run in parallel. Deprecated in favor of `n_runners`."""
|
||||
|
||||
max_tasks: Optional[int]
|
||||
"""Maximum number of tasks to process per runner. Deprecated in favor of `max_rollouts`."""
|
||||
|
||||
daemon: bool
|
||||
"""Whether worker processes should be daemons. Daemon processes
|
||||
are terminated automatically when the main process exits. Deprecated.
|
||||
Only have effect with `fit_v0`."""
|
||||
|
||||
triplet_exporter: TraceAdapter[Any]
|
||||
"""An instance of [`TracerTraceToTriplet`][agentlightning.TracerTraceToTriplet] to export triplets from traces,
|
||||
or a dictionary with the initialization parameters for the exporter.
|
||||
Deprecated. Use [`adapter`][agentlightning.Trainer.adapter] instead."""
|
||||
|
||||
port: Optional[int]
|
||||
"""Port forwarded to [`ClientServerExecutionStrategy`][agentlightning.ClientServerExecutionStrategy]."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -79,12 +124,13 @@ class Trainer(TrainerLegacy):
|
||||
n_runners: Optional[int] = None,
|
||||
max_rollouts: Optional[int] = None,
|
||||
initial_resources: Optional[NamedResources] = None,
|
||||
tracer: ComponentSpec[BaseTracer] = None,
|
||||
tracer: ComponentSpec[Tracer] = None,
|
||||
adapter: ComponentSpec[TraceAdapter[Any]] = None,
|
||||
store: ComponentSpec[LightningStore] = None,
|
||||
runner: ComponentSpec[BaseRunner[Any]] = None,
|
||||
runner: ComponentSpec[Runner[Any]] = None,
|
||||
strategy: ComponentSpec[ExecutionStrategy] = None,
|
||||
algorithm: ComponentSpec[BaseAlgorithm] = None,
|
||||
port: Optional[int] = None,
|
||||
algorithm: ComponentSpec[Algorithm] = None,
|
||||
llm_proxy: ComponentSpec[LLMProxy] = None,
|
||||
n_workers: Optional[int] = None,
|
||||
max_tasks: Optional[int] = None,
|
||||
@@ -92,6 +138,16 @@ class Trainer(TrainerLegacy):
|
||||
triplet_exporter: ComponentSpec[TracerTraceToTriplet] = None,
|
||||
hooks: Optional[Union[Hook, Sequence[Hook]]] = None,
|
||||
):
|
||||
"""Configure the trainer and resolve user-provided component specifications.
|
||||
|
||||
Each keyword accepts either a concrete instance, a class, a callable factory, a
|
||||
registry string, or a lightweight configuration dictionary (see
|
||||
[`build_component()`][agentlightning.trainer.init_utils.build_component]).
|
||||
|
||||
When ``port`` is provided it is forwarded to
|
||||
[`ClientServerExecutionStrategy`][agentlightning.ClientServerExecutionStrategy]
|
||||
instances constructed (or supplied) for the trainer.
|
||||
"""
|
||||
# Do not call super().__init__() here.
|
||||
# super().__init__() will call TrainerLegacy's initialization, which is not intended.
|
||||
self.worker_id: Optional[int] = None
|
||||
@@ -161,7 +217,13 @@ class Trainer(TrainerLegacy):
|
||||
self.store = self._make_store(store)
|
||||
self.runner = self._make_runner(runner)
|
||||
|
||||
self.strategy = self._make_strategy(strategy, n_runners=self.n_runners)
|
||||
self.port = port
|
||||
|
||||
self.strategy = self._make_strategy(
|
||||
strategy,
|
||||
n_runners=self.n_runners,
|
||||
port=port,
|
||||
)
|
||||
if hasattr(self.strategy, "n_runners"):
|
||||
strategy_runners = getattr(self.strategy, "n_runners")
|
||||
if isinstance(strategy_runners, int) and strategy_runners > 0:
|
||||
@@ -179,8 +241,8 @@ class Trainer(TrainerLegacy):
|
||||
"The cleanup must be handled manually."
|
||||
)
|
||||
|
||||
def _make_tracer(self, tracer: ComponentSpec[BaseTracer]) -> BaseTracer:
|
||||
"""Creates a tracer instance based on the provided configuration."""
|
||||
def _make_tracer(self, tracer: ComponentSpec[Tracer]) -> Tracer:
|
||||
"""Resolve the tracer component from user input, falling back to AgentOpsTracer."""
|
||||
default_factory = lambda: AgentOpsTracer(
|
||||
agentops_managed=True,
|
||||
instrument_managed=True,
|
||||
@@ -188,26 +250,27 @@ class Trainer(TrainerLegacy):
|
||||
)
|
||||
return build_component(
|
||||
tracer,
|
||||
expected_type=BaseTracer,
|
||||
expected_type=Tracer,
|
||||
spec_name="tracer",
|
||||
default_factory=default_factory,
|
||||
dict_requires_type=True,
|
||||
invalid_spec_error_fmt="Invalid tracer type: {actual_type}. Expected BaseTracer, str, dict, or None.",
|
||||
type_error_fmt="Tracer factory returned {type_name}, which is not a BaseTracer subclass.",
|
||||
invalid_spec_error_fmt="Invalid tracer type: {actual_type}. Expected Tracer, str, dict, or None.",
|
||||
type_error_fmt="Tracer factory returned {type_name}, which is not a Tracer subclass.",
|
||||
)
|
||||
|
||||
def _make_algorithm(self, algorithm: ComponentSpec[BaseAlgorithm]) -> Optional[BaseAlgorithm]:
|
||||
"""Creates an algorithm instance based on the provided configuration."""
|
||||
def _make_algorithm(self, algorithm: ComponentSpec[Algorithm]) -> Optional[Algorithm]:
|
||||
"""Resolve the algorithm component, allowing `None` for dev-mode dry runs."""
|
||||
return build_component(
|
||||
algorithm,
|
||||
expected_type=BaseAlgorithm,
|
||||
expected_type=Algorithm,
|
||||
spec_name="algorithm",
|
||||
allow_none=True,
|
||||
invalid_spec_error_fmt="Invalid algorithm type: {actual_type}. Expected BaseAlgorithm, str, dict, or None.",
|
||||
type_error_fmt="Algorithm factory returned {type_name}, which is not a BaseAlgorithm subclass.",
|
||||
invalid_spec_error_fmt="Invalid algorithm type: {actual_type}. Expected Algorithm, str, dict, or None.",
|
||||
type_error_fmt="Algorithm factory returned {type_name}, which is not a Algorithm subclass.",
|
||||
)
|
||||
|
||||
def _make_adapter(self, adapter: ComponentSpec[TraceAdapter[Any]]) -> TraceAdapter[Any]:
|
||||
"""Resolve the adapter used to transform spans into algorithm-ready payloads."""
|
||||
return build_component(
|
||||
adapter,
|
||||
expected_type=TraceAdapter,
|
||||
@@ -220,6 +283,7 @@ class Trainer(TrainerLegacy):
|
||||
)
|
||||
|
||||
def _make_store(self, store: ComponentSpec[LightningStore]) -> LightningStore:
|
||||
"""Resolve the store implementation backing rollouts, attempts, spans, and resources."""
|
||||
return build_component(
|
||||
store,
|
||||
expected_type=LightningStore,
|
||||
@@ -234,13 +298,21 @@ class Trainer(TrainerLegacy):
|
||||
strategy: ComponentSpec[ExecutionStrategy],
|
||||
*,
|
||||
n_runners: int,
|
||||
port: Optional[int] = None,
|
||||
) -> ExecutionStrategy:
|
||||
"""Resolve the execution strategy and seed defaults such as `n_runners`."""
|
||||
if isinstance(strategy, ExecutionStrategy):
|
||||
if port is not None and isinstance(strategy, ClientServerExecutionStrategy):
|
||||
strategy.server_port = port
|
||||
return strategy
|
||||
optional_defaults: Dict[str, Callable[[], Any]] = {"n_runners": lambda: n_runners}
|
||||
if port is not None:
|
||||
optional_defaults["server_port"] = lambda: port
|
||||
|
||||
def default_factory() -> ExecutionStrategy:
|
||||
return ClientServerExecutionStrategy(n_runners=n_runners, role="both")
|
||||
if port is not None:
|
||||
return ClientServerExecutionStrategy(n_runners=n_runners, server_port=port)
|
||||
return ClientServerExecutionStrategy(n_runners=n_runners)
|
||||
|
||||
return build_component(
|
||||
strategy,
|
||||
@@ -259,6 +331,7 @@ class Trainer(TrainerLegacy):
|
||||
*,
|
||||
store: LightningStore,
|
||||
) -> Optional[LLMProxy]:
|
||||
"""Resolve an optional LLM proxy and ensure it shares the trainer's store instance."""
|
||||
if isinstance(llm_proxy, LLMProxy):
|
||||
return llm_proxy
|
||||
|
||||
@@ -277,25 +350,27 @@ class Trainer(TrainerLegacy):
|
||||
type_error_fmt="llm_proxy factory returned {type_name}, which is not an LLMProxy subclass.",
|
||||
)
|
||||
|
||||
def _make_runner(self, runner: ComponentSpec[BaseRunner[Any]]) -> BaseRunner[Any]:
|
||||
def _make_runner(self, runner: ComponentSpec[Runner[Any]]) -> Runner[Any]:
|
||||
"""Resolve the runner responsible for executing the agent inside each worker."""
|
||||
optional_defaults: Dict[str, Callable[[], Any]] = {"tracer": lambda: self.tracer}
|
||||
if self.max_rollouts is not None:
|
||||
optional_defaults["max_rollouts"] = lambda: self.max_rollouts
|
||||
|
||||
def default_runner_factory() -> BaseRunner[Any]:
|
||||
def default_runner_factory() -> Runner[Any]:
|
||||
return instantiate_component(LitAgentRunner, optional_defaults=optional_defaults)
|
||||
|
||||
return build_component(
|
||||
runner,
|
||||
expected_type=BaseRunner,
|
||||
expected_type=Runner,
|
||||
spec_name="runner",
|
||||
default_factory=default_runner_factory,
|
||||
optional_defaults=optional_defaults,
|
||||
invalid_spec_error_fmt="Invalid runner type: {actual_type}. Expected BaseRunner, callable, str, dict, or None.",
|
||||
type_error_fmt="Runner factory returned {type_name}, which is not a BaseRunner subclass.",
|
||||
invalid_spec_error_fmt="Invalid runner type: {actual_type}. Expected Runner, callable, str, dict, or None.",
|
||||
type_error_fmt="Runner factory returned {type_name}, which is not a Runner subclass.",
|
||||
)
|
||||
|
||||
def _normalize_hooks(self, hooks: Optional[Union[Hook, Sequence[Hook]]]) -> Sequence[Hook]:
|
||||
"""Coerce hook inputs into an immutable sequence for runner initialization."""
|
||||
if hooks is None:
|
||||
return ()
|
||||
if isinstance(hooks, Hook):
|
||||
@@ -309,13 +384,33 @@ class Trainer(TrainerLegacy):
|
||||
*,
|
||||
val_dataset: Optional[Dataset[T_co]] = None,
|
||||
) -> None:
|
||||
"""Run the training loop using the configured strategy, store, and runner.
|
||||
"""Execute the full algorithm/runner training loop.
|
||||
|
||||
[`Trainer.fit`][agentlightning.Trainer.fit] packages the algorithm and runner bundles,
|
||||
then hands them to the active [`ExecutionStrategy`][agentlightning.ExecutionStrategy].
|
||||
The strategy rarely returns until:
|
||||
|
||||
* The algorithm exhausts the dataset(s) and stops enqueuing rollouts.
|
||||
* `max_rollouts` causes individual runners to exit.
|
||||
* An exception or interrupt cancels the shared [`ExecutionEvent`][agentlightning.ExecutionEvent].
|
||||
|
||||
Args:
|
||||
agent: The LitAgent instance to be trained on.
|
||||
train_dataset: The dataset to train on.
|
||||
val_dataset: The dataset to validate on.
|
||||
agent: [`LitAgent`][agentlightning.LitAgent] implementation executed by runners.
|
||||
train_dataset: Optional iterable of rollout inputs consumed by the algorithm.
|
||||
val_dataset: Optional iterable consumed by validation passes.
|
||||
"""
|
||||
if isinstance(train_dataset, str):
|
||||
logger.warning(
|
||||
"Trainer.fit will no longer accepts a string URL in future version. "
|
||||
"To continue using a string URL, please use Trainer.fit_v0 instead. "
|
||||
"See documentation for how to migrate to latest version: https://microsoft.github.io/agent-lightning/stable/"
|
||||
)
|
||||
return self.fit_v0( # type: ignore
|
||||
agent,
|
||||
train_dataset,
|
||||
val_dataset, # type: ignore
|
||||
)
|
||||
|
||||
agent.set_trainer(self)
|
||||
|
||||
algorithm_bundle = functools.partial(
|
||||
@@ -335,15 +430,22 @@ class Trainer(TrainerLegacy):
|
||||
*,
|
||||
val_dataset: Optional[Dataset[T_co]] = None,
|
||||
) -> None:
|
||||
"""Dry run the training loop with a FastAlgorithm and the real runner.
|
||||
"""Exercise the infrastructure using a fast, synchronous algorithm.
|
||||
|
||||
[`Trainer.dev`][agentlightning.Trainer.dev] mirrors [`fit()`][agentlightning.Trainer.fit] but
|
||||
insists on an [`Algorithm`][agentlightning.Algorithm] subtype that also derives from
|
||||
[`FastAlgorithm`][agentlightning.FastAlgorithm]. This keeps the loop responsive for
|
||||
debugging while still touching the same store, runners, hooks, and tracer plumbing.
|
||||
|
||||
If no algorithm is provided, a default [`Baseline`][agentlightning.Baseline] algorithm will be used.
|
||||
|
||||
Args:
|
||||
agent: The LitAgent instance to be trained on.
|
||||
train_dataset: The dataset to train on.
|
||||
val_dataset: The dataset to validate on.
|
||||
agent: [`LitAgent`][agentlightning.LitAgent] implementation to execute.
|
||||
train_dataset: Optional iterable passed to the algorithm.
|
||||
val_dataset: Optional iterable passed to the algorithm.
|
||||
|
||||
Raises:
|
||||
TypeError: If the configured algorithm is not a :class:`FastAlgorithm`.
|
||||
TypeError: If the configured algorithm does not inherit from `FastAlgorithm`.
|
||||
"""
|
||||
agent.set_trainer(self)
|
||||
|
||||
@@ -374,8 +476,17 @@ class Trainer(TrainerLegacy):
|
||||
event: ExecutionEvent,
|
||||
train_dataset: Optional[Dataset[T_co]],
|
||||
val_dataset: Optional[Dataset[T_co]],
|
||||
algorithm: Optional[BaseAlgorithm],
|
||||
algorithm: Optional[Algorithm],
|
||||
) -> None:
|
||||
"""Internal entry point executed by the strategy for the algorithm role.
|
||||
|
||||
This coroutine is scheduled inside the strategy's process/thread and is responsible
|
||||
for binding algorithm dependencies (store, adapter, initial resources, proxy) before
|
||||
invoking [`Algorithm.run`][agentlightning.Algorithm.run].
|
||||
When `algorithm` is `None` the bundle simply waits for the
|
||||
shared `event` to signal shutdown so runners can still execute (useful for manual queue
|
||||
seeding or external algorithms).
|
||||
"""
|
||||
if algorithm is not None:
|
||||
algorithm.set_trainer(self)
|
||||
algorithm.set_store(store)
|
||||
@@ -410,7 +521,14 @@ class Trainer(TrainerLegacy):
|
||||
async def _runner_bundle(
|
||||
self, store: LightningStore, worker_id: int, event: ExecutionEvent, agent: LitAgent[T_co]
|
||||
) -> None:
|
||||
runner_instance: BaseRunner[Any] | None = None
|
||||
"""Internal entry point executed by the strategy for each runner role.
|
||||
|
||||
The bundle materializes the configured runner, binds the agent and hooks, associates
|
||||
the worker with the shared store, and then drives the runner's [`iter`][agentlightning.Runner.iter]
|
||||
loop until the execution event is set or an exception occurs. Cleanup mirrors the initialization
|
||||
sequence to keep tracer state, hooks, and agent resources consistent across restarts.
|
||||
"""
|
||||
runner_instance: Runner[Any] | None = None
|
||||
runner_initialized = False
|
||||
worker_initialized = False
|
||||
try:
|
||||
|
||||
+253
-58
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Core data models shared across Agent Lightning components."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import (
|
||||
@@ -8,14 +10,19 @@ from typing import (
|
||||
Callable,
|
||||
Dict,
|
||||
Generic,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
Optional,
|
||||
Protocol,
|
||||
Sequence,
|
||||
SupportsIndex,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
overload,
|
||||
)
|
||||
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
@@ -25,8 +32,8 @@ from .tracer import Span
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agentlightning.litagent import LitAgent
|
||||
from agentlightning.runner.base import BaseRunner
|
||||
from agentlightning.tracer.base import BaseTracer
|
||||
from agentlightning.runner.base import Runner
|
||||
from agentlightning.tracer.base import Tracer
|
||||
|
||||
__all__ = [
|
||||
"Triplet",
|
||||
@@ -47,13 +54,19 @@ __all__ = [
|
||||
"Attempt",
|
||||
"AttemptedRollout",
|
||||
"Hook",
|
||||
"Worker",
|
||||
"WorkerStatus",
|
||||
"PaginatedResult",
|
||||
"FilterOptions",
|
||||
"SortOptions",
|
||||
"FilterField",
|
||||
]
|
||||
|
||||
T_co = TypeVar("T_co", covariant=True)
|
||||
|
||||
|
||||
class Triplet(BaseModel):
|
||||
"""A standard structure for a single turn in a trajectory."""
|
||||
"""Single interaction turn captured during reinforcement learning."""
|
||||
|
||||
prompt: Any
|
||||
response: Any
|
||||
@@ -62,7 +75,11 @@ class Triplet(BaseModel):
|
||||
|
||||
|
||||
class RolloutLegacy(BaseModel):
|
||||
"""The standard reporting object from client to server."""
|
||||
"""Legacy reporting payload exchanged with the deprecated HTTP server.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
Use [`Rollout`][agentlightning.Rollout] instead.
|
||||
"""
|
||||
|
||||
rollout_id: str
|
||||
|
||||
@@ -97,6 +114,7 @@ RolloutStatus = Literal[
|
||||
"cancelled", # cancelled by user (or watchdog)
|
||||
"requeuing", # retrying
|
||||
]
|
||||
"""The status of a rollout."""
|
||||
|
||||
AttemptStatus = Literal[
|
||||
# A status is essentially a process.
|
||||
@@ -108,66 +126,83 @@ AttemptStatus = Literal[
|
||||
"unresponsive", # the worker has not reported results for a while
|
||||
"timeout", # the worker has been emitting new logs, but have been working on the task for too long
|
||||
]
|
||||
"""The status of an attempt."""
|
||||
|
||||
RolloutMode = Literal["train", "val", "test"]
|
||||
"""Possible rollout modes."""
|
||||
|
||||
|
||||
class Attempt(BaseModel):
|
||||
"""An attempt to execute a rollout. A rollout can have multiple attempts if retries are needed."""
|
||||
|
||||
rollout_id: str # the rollout this attempt belongs to
|
||||
attempt_id: str # the universal id for current attempt
|
||||
sequence_id: int # the sequence number of the attempt, starting from 1
|
||||
start_time: float # time when the attempt has started
|
||||
end_time: Optional[float] = None # time when the attempt has ended
|
||||
"""Execution attempt for a rollout, including metadata for retries."""
|
||||
|
||||
rollout_id: str
|
||||
"""The rollout which this attempt belongs to."""
|
||||
attempt_id: str
|
||||
"""The universal id for current attempt."""
|
||||
sequence_id: int
|
||||
"""The sequence number of the attempt, starting from 1."""
|
||||
start_time: float
|
||||
"""The time when the attempt has started."""
|
||||
end_time: Optional[float] = None
|
||||
"""The time when the attempt has ended."""
|
||||
status: AttemptStatus = "preparing"
|
||||
# The rollout worker which is executing this attempt
|
||||
"""The status of the attempt."""
|
||||
worker_id: Optional[str] = None
|
||||
"""The rollout worker which is executing this attempt."""
|
||||
|
||||
last_heartbeat_time: Optional[float] = None # last time when the worker has reported progress
|
||||
last_heartbeat_time: Optional[float] = None
|
||||
"""The last time when the worker has reported progress (i.e., a span)."""
|
||||
|
||||
# A bucket for any other relevant information
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
"""A bucket for any other relevant information."""
|
||||
|
||||
|
||||
class RolloutConfig(BaseModel):
|
||||
"""Configurations for rollout execution."""
|
||||
"""Configuration controlling rollout retries and timeouts."""
|
||||
|
||||
timeout_seconds: Optional[float] = None # none indicates no timeout
|
||||
unresponsive_seconds: Optional[float] = None # none indicates no unresponsive timeout
|
||||
max_attempts: int = Field(default=1, ge=1) # including the first attempt
|
||||
retry_condition: List[AttemptStatus] = Field(
|
||||
default_factory=cast(Callable[[], List[AttemptStatus]], list)
|
||||
) # list of statuses that should trigger a retry
|
||||
timeout_seconds: Optional[float] = None
|
||||
"""The timeout for the rollout, in seconds. None indicates no timeout."""
|
||||
unresponsive_seconds: Optional[float] = None
|
||||
"""The unresponsive timeout for the rollout, in seconds. None indicates no unresponsive timeout."""
|
||||
max_attempts: int = Field(default=1, ge=1)
|
||||
"""The maximum number of attempts for the rollout, including the first attempt."""
|
||||
retry_condition: List[AttemptStatus] = Field(default_factory=cast(Callable[[], List[AttemptStatus]], list))
|
||||
"""The list of statuses that should trigger a retry."""
|
||||
|
||||
|
||||
class Rollout(BaseModel):
|
||||
rollout_id: str
|
||||
"""Unique identifier for the rollout."""
|
||||
|
||||
# Inputs
|
||||
input: TaskInput
|
||||
"""Task input used to generate the rollout."""
|
||||
|
||||
# Time to track the lifecycle of the rollout
|
||||
start_time: float
|
||||
"""Timestamp when the rollout started."""
|
||||
end_time: Optional[float] = None
|
||||
"""Timestamp when the rollout ended."""
|
||||
|
||||
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."""
|
||||
|
||||
# Overall scheduling/running information
|
||||
status: RolloutStatus = "queuing"
|
||||
"""Latest status emitted by the controller."""
|
||||
|
||||
config: RolloutConfig = Field(default_factory=RolloutConfig)
|
||||
"""Retry and timeout configuration associated with the rollout."""
|
||||
|
||||
# A bucket for any other relevant information
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
"""Additional metadata attached to the rollout."""
|
||||
|
||||
|
||||
class AttemptedRollout(Rollout):
|
||||
"""A rollout along with its active attempt."""
|
||||
"""Rollout paired with the currently active attempt."""
|
||||
|
||||
attempt: Attempt
|
||||
"""The attempt that is currently processing the rollout."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_consistency(self) -> AttemptedRollout:
|
||||
@@ -176,12 +211,43 @@ class AttemptedRollout(Rollout):
|
||||
return self
|
||||
|
||||
|
||||
WorkerStatus = Literal["idle", "busy", "unknown"]
|
||||
|
||||
|
||||
class Worker(BaseModel):
|
||||
"""Worker information. This is actually the same as Runner info."""
|
||||
|
||||
worker_id: str
|
||||
"""The ID of the worker."""
|
||||
status: WorkerStatus = "unknown"
|
||||
"""The status of the worker."""
|
||||
heartbeat_stats: Optional[Dict[str, Any]] = None
|
||||
"""Statistics about the worker's heartbeat."""
|
||||
last_heartbeat_time: Optional[float] = None
|
||||
"""The last time when the worker has reported the stats."""
|
||||
last_dequeue_time: Optional[float] = None
|
||||
"""The last time when the worker has tried to dequeue a rollout."""
|
||||
last_busy_time: Optional[float] = None
|
||||
"""The last time when the worker has started an attempt and became busy."""
|
||||
last_idle_time: Optional[float] = None
|
||||
"""The last time when the worker has triggered the end of an attempt and became idle."""
|
||||
current_rollout_id: Optional[str] = None
|
||||
"""The ID of the current rollout that the worker is processing."""
|
||||
current_attempt_id: Optional[str] = None
|
||||
"""The ID of the current attempt that the worker is processing."""
|
||||
|
||||
|
||||
TaskInput = Any
|
||||
"""Task input type. Can be any type."""
|
||||
"""Task input type. Accepts arbitrary payloads."""
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
"""A task (rollout request) to be processed by the client agent. Deprecated."""
|
||||
"""Rollout request served to client agents.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
The legacy HTTP client/server stack still uses this model. Prefer
|
||||
[`LightningStore`][agentlightning.LightningStore] APIs for new workflows.
|
||||
"""
|
||||
|
||||
rollout_id: str
|
||||
input: TaskInput
|
||||
@@ -199,11 +265,23 @@ class Task(BaseModel):
|
||||
|
||||
|
||||
class TaskIfAny(BaseModel):
|
||||
"""A task or indication that no task is available.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
Use [`LightningStore`][agentlightning.LightningStore] APIs for new workflows.
|
||||
"""
|
||||
|
||||
is_available: bool
|
||||
"""Indication that a task is available."""
|
||||
task: Optional[Task] = None
|
||||
|
||||
|
||||
RolloutRawResultLegacy = Union[None, float, List[Triplet], List[Dict[str, Any]], List[ReadableSpan], RolloutLegacy]
|
||||
"""Legacy rollout result type.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
Use [`RolloutRawResult`][agentlightning.RolloutRawResult] instead.
|
||||
"""
|
||||
|
||||
RolloutRawResult = Union[
|
||||
None, # nothing (relies on tracer)
|
||||
@@ -211,11 +289,23 @@ RolloutRawResult = Union[
|
||||
List[ReadableSpan], # constructed OTEL spans by user
|
||||
List[Span], # constructed Span objects by user
|
||||
]
|
||||
"""Rollout result type.
|
||||
|
||||
Possible return values of [`rollout`][agentlightning.LitAgent.rollout].
|
||||
"""
|
||||
|
||||
|
||||
class GenericResponse(BaseModel):
|
||||
"""
|
||||
A generic response message that can be used for various purposes.
|
||||
"""Generic server response used by compatibility endpoints.
|
||||
|
||||
!!! warning "Deprecated"
|
||||
This response is no longer used by the new
|
||||
[`LightningStore`][agentlightning.LightningStore] APIs.
|
||||
|
||||
Attributes:
|
||||
status: Status string describing the result of the request.
|
||||
message: Optional human readable explanation.
|
||||
data: Arbitrary payload serialized as JSON.
|
||||
"""
|
||||
|
||||
status: str = "success"
|
||||
@@ -224,19 +314,18 @@ class GenericResponse(BaseModel):
|
||||
|
||||
|
||||
class ParallelWorkerBase:
|
||||
"""Base class for objects that can be parallelized across multiple worker processes.
|
||||
"""Base class for workloads executed across multiple worker processes.
|
||||
|
||||
This class defines the standard lifecycle for parallel processing:
|
||||
The lifecycle is orchestrated by the main process:
|
||||
|
||||
Main Process:
|
||||
1. init() - Initialize the object in the main process
|
||||
2. spawn workers and call init_worker() in each worker
|
||||
3. run() - Execute the main workload in parallel across workers
|
||||
4. teardown_worker() - Clean up resources in each worker
|
||||
5. teardown() - Final cleanup in the main process
|
||||
* [`init()`][agentlightning.ParallelWorkerBase.init] prepares shared state.
|
||||
* Each worker calls [`init_worker()`][agentlightning.ParallelWorkerBase.init_worker] during start-up.
|
||||
* [`run()`][agentlightning.ParallelWorkerBase.run] performs the parallel workload.
|
||||
* Workers call [`teardown_worker()`][agentlightning.ParallelWorkerBase.teardown_worker] before exiting.
|
||||
* The main process finalizes through [`teardown()`][agentlightning.ParallelWorkerBase.teardown].
|
||||
|
||||
Subclasses should implement the run() method and optionally override
|
||||
the lifecycle methods for custom initialization and cleanup behavior.
|
||||
Subclasses must implement [`run()`][agentlightning.ParallelWorkerBase.run]
|
||||
and can override other lifecycle hooks.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -244,25 +333,30 @@ class ParallelWorkerBase:
|
||||
self.worker_id: Optional[int] = None
|
||||
|
||||
def init(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Initialize before spawning the workers. This method can be overridden by subclasses."""
|
||||
pass
|
||||
|
||||
def init_worker(self, worker_id: int, *args: Any, **kwargs: Any) -> None:
|
||||
"""Initialize the worker. This method can be overridden by subclasses."""
|
||||
self.worker_id = worker_id
|
||||
|
||||
def run(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Run the workload. This method can be overridden by subclasses."""
|
||||
pass
|
||||
|
||||
def teardown_worker(self, worker_id: int, *args: Any, **kwargs: Any) -> None:
|
||||
"""Teardown the worker. This method can be overridden by subclasses."""
|
||||
pass
|
||||
|
||||
def teardown(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Teardown after the workers have exited. This method can be overridden by subclasses."""
|
||||
pass
|
||||
|
||||
|
||||
class Dataset(Protocol, Generic[T_co]):
|
||||
"""The general interface for a dataset.
|
||||
|
||||
It's currently implemented as a protocol, having a similar interface to torch.utils.data.Dataset.
|
||||
It's currently implemented as a protocol, having a similar interface to `torch.utils.data.Dataset`.
|
||||
You don't have to inherit from this class; you can use a simple list if you want to.
|
||||
"""
|
||||
|
||||
@@ -275,42 +369,42 @@ class Hook(ParallelWorkerBase):
|
||||
"""Base class for defining hooks in the agent runner's lifecycle."""
|
||||
|
||||
async def on_trace_start(
|
||||
self, *, agent: LitAgent[Any], runner: BaseRunner[Any], tracer: BaseTracer, rollout: Rollout
|
||||
self, *, agent: LitAgent[Any], runner: Runner[Any], tracer: Tracer, rollout: Rollout
|
||||
) -> None:
|
||||
"""Hook called immediately after the tracer enters the trace context but before the rollout begins.
|
||||
|
||||
Args:
|
||||
agent: The :class:`LitAgent` instance associated with the runner.
|
||||
runner: The :class:`BaseRunner` managing the rollout.
|
||||
tracer: The :class:`BaseTracer` instance associated with the runner.
|
||||
rollout: The :class:`Rollout` object that will be processed.
|
||||
agent: The [`LitAgent`][agentlightning.LitAgent] instance associated with the runner.
|
||||
runner: The [`Runner`][agentlightning.Runner] managing the rollout.
|
||||
tracer: The [`Tracer`][agentlightning.Tracer] instance associated with the runner.
|
||||
rollout: The [`Rollout`][agentlightning.Rollout] object that will be processed.
|
||||
|
||||
Subclasses can override this method to implement custom logic such as logging,
|
||||
metric collection, or resource setup. By default, this is a no-op.
|
||||
"""
|
||||
|
||||
async def on_trace_end(
|
||||
self, *, agent: LitAgent[Any], runner: BaseRunner[Any], tracer: BaseTracer, rollout: Rollout
|
||||
self, *, agent: LitAgent[Any], runner: Runner[Any], tracer: Tracer, rollout: Rollout
|
||||
) -> None:
|
||||
"""Hook called immediately after the rollout completes but before the tracer exits the trace context.
|
||||
|
||||
Args:
|
||||
agent: The :class:`LitAgent` instance associated with the runner.
|
||||
runner: The :class:`BaseRunner` managing the rollout.
|
||||
tracer: The :class:`BaseTracer` instance associated with the runner.
|
||||
rollout: The :class:`Rollout` object that has been processed.
|
||||
agent: The [`LitAgent`][agentlightning.LitAgent] instance associated with the runner.
|
||||
runner: The [`Runner`][agentlightning.Runner] managing the rollout.
|
||||
tracer: The [`Tracer`][agentlightning.Tracer] instance associated with the runner.
|
||||
rollout: The [`Rollout`][agentlightning.Rollout] object that has been processed.
|
||||
|
||||
Subclasses can override this method to implement custom logic such as logging,
|
||||
metric collection, or resource cleanup. By default, this is a no-op.
|
||||
"""
|
||||
|
||||
async def on_rollout_start(self, *, agent: LitAgent[Any], runner: BaseRunner[Any], rollout: Rollout) -> None:
|
||||
async def on_rollout_start(self, *, agent: LitAgent[Any], runner: Runner[Any], rollout: Rollout) -> None:
|
||||
"""Hook called immediately before a rollout *attempt* begins.
|
||||
|
||||
Args:
|
||||
agent: The :class:`LitAgent` instance associated with the runner.
|
||||
runner: The :class:`BaseRunner` managing the rollout.
|
||||
rollout: The :class:`Rollout` object that will be processed.
|
||||
agent: The [`LitAgent`][agentlightning.LitAgent] instance associated with the runner.
|
||||
runner: The [`Runner`][agentlightning.Runner] managing the rollout.
|
||||
rollout: The [`Rollout`][agentlightning.Rollout] object that will be processed.
|
||||
|
||||
Subclasses can override this method to implement custom logic such as
|
||||
logging, metric collection, or resource setup. By default, this is a
|
||||
@@ -321,18 +415,119 @@ class Hook(ParallelWorkerBase):
|
||||
self,
|
||||
*,
|
||||
agent: LitAgent[Any],
|
||||
runner: BaseRunner[Any],
|
||||
runner: Runner[Any],
|
||||
rollout: Rollout,
|
||||
spans: Union[List[ReadableSpan], List[Span]],
|
||||
) -> None:
|
||||
"""Hook called after a rollout *attempt* completes.
|
||||
|
||||
Args:
|
||||
agent: The :class:`LitAgent` instance associated with the runner.
|
||||
runner: The :class:`BaseRunner` managing the rollout.
|
||||
rollout: The :class:`Rollout` object that has been processed.
|
||||
agent: The [`LitAgent`][agentlightning.LitAgent] instance associated with the runner.
|
||||
runner: The [`Runner`][agentlightning.Runner] managing the rollout.
|
||||
rollout: The [`Rollout`][agentlightning.Rollout] object that has been processed.
|
||||
spans: The spans that have been added to the store.
|
||||
|
||||
Subclasses can override this method for cleanup or additional
|
||||
logging. By default, this is a no-op.
|
||||
"""
|
||||
|
||||
|
||||
class FilterField(TypedDict, total=False):
|
||||
"""An operator dict for a single field."""
|
||||
|
||||
exact: Any
|
||||
within: Sequence[Any]
|
||||
contains: str
|
||||
|
||||
|
||||
FilterOptions = Mapping[
|
||||
Union[str, Literal["_aggregate", "_must"]],
|
||||
Union[FilterField, Literal["and", "or"], Mapping[str, FilterField]],
|
||||
]
|
||||
"""A mapping of field name -> operator dict.
|
||||
|
||||
Each operator dict can contain:
|
||||
|
||||
- "exact": value for exact equality.
|
||||
- "within": iterable of allowed values.
|
||||
- "contains": substring to search for in string fields.
|
||||
|
||||
The filter can also have a special field called "_aggregate" that can be used to specify the logic
|
||||
to combine the results of the filters:
|
||||
|
||||
- "and": all conditions must match. This is the default value if not specified.
|
||||
- "or": at least one condition must match.
|
||||
|
||||
All conditions within a field and between different fields are
|
||||
stored in a unified pool and combined using `_aggregate`.
|
||||
|
||||
The filter can also have a special group called "_must", which is a mapping of filters that must all match,
|
||||
no matter whether the aggregate logic is "and" or "or".
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"_aggregate": "or",
|
||||
"_must": {
|
||||
"city": {"exact": "New York"},
|
||||
"timezone": {"within": ["America/New_York", "America/Los_Angeles"]},
|
||||
},
|
||||
"status": {"exact": "active"},
|
||||
"id": {"within": [1, 2, 3]},
|
||||
"name": {"contains": "foo"},
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
class SortOptions(TypedDict):
|
||||
"""Options for sorting the collection."""
|
||||
|
||||
name: str
|
||||
"""The name of the field to sort by."""
|
||||
order: Literal["asc", "desc"]
|
||||
"""The order to sort by."""
|
||||
|
||||
|
||||
T_item = TypeVar("T_item")
|
||||
|
||||
|
||||
class PaginatedResult(BaseModel, Sequence[T_item]):
|
||||
"""Result of a paginated query.
|
||||
|
||||
Behaves like a sequence, but also carries pagination metadata (limit, offset, total).
|
||||
"""
|
||||
|
||||
items: Sequence[T_item]
|
||||
"""Items in the result."""
|
||||
limit: int
|
||||
"""Limit of the result."""
|
||||
offset: int
|
||||
"""Offset of the result."""
|
||||
total: int
|
||||
"""Total number of items in the collection."""
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.items)
|
||||
|
||||
@overload
|
||||
def __getitem__(self, index: int) -> T_item: ...
|
||||
|
||||
@overload
|
||||
def __getitem__(self, index: slice) -> Sequence[T_item]: ...
|
||||
|
||||
def __getitem__(self, index: Union[int, slice]) -> Union[T_item, Sequence[T_item]]:
|
||||
return self.items[index]
|
||||
|
||||
# Overriding __iter__ enables list(paginated_result) to work as expected,
|
||||
# but changes Pydantic's default dict iteration behavior (which would otherwise
|
||||
# iterate over field names).
|
||||
def __iter__(self) -> Iterator[T_item]: # type: ignore
|
||||
return iter(self.items)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
first_item_repr = repr(self.items[0]) if self.items else "empty"
|
||||
items_repr = f"[{first_item_repr}, ...]" if len(self.items) > 1 else first_item_repr
|
||||
slice_repr = f"{self.offset}:" if self.limit == -1 else f"{self.offset}:{self.offset + self.limit}"
|
||||
return f"<PaginatedResult ({slice_repr} of {self.total}) {items_repr}>"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""Typed representations of tunable resources shared between Agent Lightning components."""
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from typing import (
|
||||
@@ -32,40 +34,40 @@ __all__ = [
|
||||
|
||||
|
||||
class Resource(BaseModel):
|
||||
"""
|
||||
Base class for all tunable resources.
|
||||
"""
|
||||
"""Base class for tunable resources distributed to executors."""
|
||||
|
||||
resource_type: Any
|
||||
"""Alias of the resource type."""
|
||||
|
||||
|
||||
class LLM(Resource):
|
||||
"""
|
||||
Provide an LLM endpoint and model name as a resource.
|
||||
|
||||
Attributes:
|
||||
endpoint (str): The URL of the LLM API endpoint.
|
||||
model (str): The identifier for the model to be used (e.g., 'gpt-4o').
|
||||
sampling_parameters (SamplingParameters): A dictionary of hyperparameters
|
||||
for model inference, such as temperature, top_p, etc.
|
||||
"""
|
||||
"""Resource that identifies an LLM endpoint and its configuration."""
|
||||
|
||||
resource_type: Literal["llm"] = "llm"
|
||||
endpoint: str
|
||||
"""The URL of the LLM API endpoint."""
|
||||
model: str
|
||||
"""The identifier for the model to be used (e.g., 'gpt-4o')."""
|
||||
api_key: Optional[str] = None
|
||||
"""Optional secret used to authenticate requests."""
|
||||
sampling_parameters: Dict[str, Any] = Field(default_factory=dict)
|
||||
"""A dictionary of hyperparameters for model inference, such as temperature, top_p, etc."""
|
||||
|
||||
def get_base_url(self, *args: Any, **kwargs: Any) -> str:
|
||||
"""The base_url to put into openai.OpenAI.
|
||||
"""Return the base URL consumed by OpenAI-compatible clients.
|
||||
|
||||
Users are encouraged to use `base_url` to get the LLM endpoint instead of accessing `endpoint` directly.
|
||||
Users are encouraged to use `get_base_url(rollout_id, attempt_id)` to get
|
||||
the LLM endpoint instead of accessing `.endpoint` directly.
|
||||
"""
|
||||
return self.endpoint
|
||||
|
||||
|
||||
class ProxyLLM(LLM):
|
||||
"""Proxy LLM resource that is tailored by `llm_proxy.LLMProxy`."""
|
||||
"""LLM resource that rewrites endpoints through [`LLMProxy`][agentlightning.LLMProxy].
|
||||
|
||||
The proxy injects rollout- and attempt-specific routing information into the
|
||||
endpoint so that downstream services can attribute requests correctly.
|
||||
"""
|
||||
|
||||
resource_type: Literal["proxy_llm"] = "proxy_llm" # type: ignore
|
||||
_initialized: bool = False
|
||||
@@ -76,7 +78,7 @@ class ProxyLLM(LLM):
|
||||
object.__setattr__(self, "_initialized", True)
|
||||
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
"""Override to emit a warning when endpoint is accessed directly."""
|
||||
"""Emit a warning when `endpoint` is accessed directly after initialization."""
|
||||
# Check if we're accessing endpoint after initialization and not from base_url
|
||||
if name == "endpoint":
|
||||
try:
|
||||
@@ -97,7 +99,7 @@ class ProxyLLM(LLM):
|
||||
return super().__getattribute__(name)
|
||||
|
||||
def with_attempted_rollout(self, rollout: AttemptedRollout) -> LLM:
|
||||
"""Bake the rollout and attempt id into the endpoint."""
|
||||
"""Bake rollout metadata into a concrete [`LLM`][agentlightning.LLM] instance."""
|
||||
return LLM(
|
||||
endpoint=self.get_base_url(rollout.rollout_id, rollout.attempt.attempt_id),
|
||||
model=self.model,
|
||||
@@ -106,6 +108,18 @@ class ProxyLLM(LLM):
|
||||
)
|
||||
|
||||
def get_base_url(self, rollout_id: Optional[str], attempt_id: Optional[str]) -> str:
|
||||
"""Return the routed endpoint for a specific rollout/attempt pair.
|
||||
|
||||
Args:
|
||||
rollout_id: Identifier of the rollout making the request.
|
||||
attempt_id: Identifier of the attempt within that rollout.
|
||||
|
||||
Returns:
|
||||
Fully qualified endpoint including rollout metadata.
|
||||
|
||||
Raises:
|
||||
ValueError: If exactly one of ``rollout_id`` or ``attempt_id`` is provided.
|
||||
"""
|
||||
if rollout_id is None and attempt_id is None:
|
||||
return self.endpoint
|
||||
|
||||
@@ -130,22 +144,20 @@ class ProxyLLM(LLM):
|
||||
|
||||
|
||||
class PromptTemplate(Resource):
|
||||
"""
|
||||
A prompt template as a resource.
|
||||
|
||||
Attributes:
|
||||
template (str): The template string. The format depends on the engine.
|
||||
engine (Literal['jinja', 'f-string', 'poml']): The templating engine
|
||||
to use for rendering the prompt. I imagine users can use their own
|
||||
customized engines, but algos can only well operate on a subset of them.
|
||||
"""
|
||||
"""Resource describing a reusable prompt template."""
|
||||
|
||||
resource_type: Literal["prompt_template"] = "prompt_template"
|
||||
template: str
|
||||
"""The template string. The format depends on the engine."""
|
||||
engine: Literal["jinja", "f-string", "poml"]
|
||||
"""The templating engine to use for rendering the prompt."""
|
||||
|
||||
def format(self, **kwargs: Any) -> str:
|
||||
"""Format the prompt template with the given kwargs."""
|
||||
"""Format the prompt using keyword arguments.
|
||||
|
||||
!!! warning
|
||||
Only the `f-string` engine is supported for now.
|
||||
"""
|
||||
if self.engine == "f-string":
|
||||
return self.template.format(**kwargs)
|
||||
else:
|
||||
@@ -158,32 +170,35 @@ class PromptTemplate(Resource):
|
||||
# TODO: migrate to use a registry
|
||||
ResourceUnion = Annotated[Union[LLM, ProxyLLM, PromptTemplate], Field(discriminator="resource_type")]
|
||||
NamedResources = Dict[str, ResourceUnion]
|
||||
"""
|
||||
A dictionary-like class to hold named resources.
|
||||
"""Mapping from resource names to their configured instances.
|
||||
|
||||
Example:
|
||||
Examples:
|
||||
```python
|
||||
resources: NamedResources = {
|
||||
'main_llm': LLM(
|
||||
"main_llm": LLM(
|
||||
endpoint="http://localhost:8080",
|
||||
model="llama3",
|
||||
sampling_parameters={'temperature': 0.7, 'max_tokens': 100}
|
||||
sampling_parameters={"temperature": 0.7, "max_tokens": 100},
|
||||
),
|
||||
'system_prompt': PromptTemplate(
|
||||
"system_prompt": PromptTemplate(
|
||||
template="You are a helpful assistant.",
|
||||
engine='f-string'
|
||||
)
|
||||
engine="f-string",
|
||||
),
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
class ResourcesUpdate(BaseModel):
|
||||
"""
|
||||
A resource update message to be sent from the server to clients.
|
||||
|
||||
This message contains a dictionary of resources that clients should use
|
||||
for subsequent tasks. It is used to update the resources available to
|
||||
clients dynamically.
|
||||
"""
|
||||
"""Update payload broadcast to clients when resources change."""
|
||||
|
||||
resources_id: str
|
||||
"""Identifier used to version the resources."""
|
||||
create_time: float
|
||||
"""Timestamp of the creation time of the resources."""
|
||||
update_time: float
|
||||
"""Timestamp of the last update time of the resources."""
|
||||
version: int
|
||||
"""Version of the resources."""
|
||||
resources: NamedResources
|
||||
"""Mapping of resource names to their definitions."""
|
||||
|
||||
+140
-34
@@ -2,17 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""Data models that mirror OpenTelemetry spans for Agent Lightning."""
|
||||
|
||||
import json
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Sequence, Union
|
||||
|
||||
from opentelemetry import trace as trace_api
|
||||
from opentelemetry.sdk.resources import Resource as OtelResource
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import Event as OtelEvent
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
from opentelemetry.sdk.trace.id_generator import RandomIdGenerator
|
||||
from opentelemetry.trace.status import Status as OtelStatus
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
__all__ = [
|
||||
"AttributeValue",
|
||||
@@ -22,7 +24,7 @@ __all__ = [
|
||||
"TraceStatus",
|
||||
"Event",
|
||||
"Link",
|
||||
"Resource",
|
||||
"OtelResource",
|
||||
"Span",
|
||||
"SpanNames",
|
||||
"SpanAttributeNames",
|
||||
@@ -31,9 +33,13 @@ __all__ = [
|
||||
|
||||
|
||||
def convert_timestamp(timestamp: Optional[int]) -> Optional[float]:
|
||||
"""Convert timestamp from nanoseconds to seconds if needed.
|
||||
"""Normalize OpenTelemetry timestamps to seconds.
|
||||
|
||||
Auto-detects format: if > 1e12, assumes nanoseconds; otherwise seconds.
|
||||
Args:
|
||||
timestamp: Timestamp expressed either in seconds or nanoseconds.
|
||||
|
||||
Returns:
|
||||
Timestamp in seconds when `timestamp` is provided; otherwise `None`.
|
||||
"""
|
||||
if not timestamp:
|
||||
return None
|
||||
@@ -41,7 +47,15 @@ def convert_timestamp(timestamp: Optional[int]) -> Optional[float]:
|
||||
|
||||
|
||||
def extract_extra_fields(src: Any, excluded_fields: List[str]) -> Dict[str, Any]:
|
||||
"""Extract extra fields from source object, excluding specified fields and private fields."""
|
||||
"""Capture custom attributes from an OpenTelemetry object.
|
||||
|
||||
Args:
|
||||
src: Object that exposes a `__dict__` of potential attributes.
|
||||
excluded_fields: Attribute names that should be removed from the output.
|
||||
|
||||
Returns:
|
||||
Dictionary containing JSON-serializable representations of the remaining fields.
|
||||
"""
|
||||
excluded_fields_set = set(excluded_fields) | set(["_" + k for k in excluded_fields])
|
||||
# Exclude the function fields
|
||||
excluded_fields_set |= set(src.__class__.__dict__.keys())
|
||||
@@ -62,23 +76,31 @@ AttributeValue = Union[
|
||||
Sequence[int],
|
||||
Sequence[float],
|
||||
]
|
||||
"""Possible values for OpenTelemetry attributes."""
|
||||
Attributes = Dict[str, AttributeValue]
|
||||
"""Mapping from attribute names to their values. Same as OpenTelemetry `Attributes` type."""
|
||||
TraceState = Dict[str, str]
|
||||
"""Mapping from trace state key to its value. Same as OpenTelemetry `TraceState` type."""
|
||||
|
||||
|
||||
class SpanContext(BaseModel):
|
||||
"""Corresponding to opentelemetry.trace.SpanContext"""
|
||||
"""Pydantic representation of `opentelemetry.trace.SpanContext` values."""
|
||||
|
||||
trace_id: str
|
||||
"""The trace ID of the span."""
|
||||
span_id: str
|
||||
"""The span ID of the span."""
|
||||
is_remote: bool
|
||||
"""Whether the span is remote."""
|
||||
trace_state: TraceState
|
||||
"""Mapping from trace state key to its value."""
|
||||
|
||||
class Config:
|
||||
allow_extra = True
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
@classmethod
|
||||
def from_opentelemetry(cls, src: trace_api.SpanContext) -> "SpanContext":
|
||||
"""Construct a [`SpanContext`][agentlightning.SpanContext] from OpenTelemetry data."""
|
||||
|
||||
return cls(
|
||||
trace_id=trace_api.format_trace_id(src.trace_id),
|
||||
span_id=trace_api.format_span_id(src.span_id),
|
||||
@@ -89,16 +111,19 @@ class SpanContext(BaseModel):
|
||||
|
||||
|
||||
class TraceStatus(BaseModel):
|
||||
"""Corresponding to opentelemetry.trace.Status"""
|
||||
"""Serializable variant of `opentelemetry.trace.Status`."""
|
||||
|
||||
status_code: str
|
||||
"""The status code of the span. Same as OpenTelemetry `Status.status_code` type."""
|
||||
description: Optional[str] = None
|
||||
"""The description of the span. Same as OpenTelemetry `Status.description` type."""
|
||||
|
||||
class Config:
|
||||
allow_extra = True
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
@classmethod
|
||||
def from_opentelemetry(cls, src: OtelStatus) -> "TraceStatus":
|
||||
"""Create a [`TraceStatus`][agentlightning.TraceStatus] from OpenTelemetry metadata."""
|
||||
|
||||
return cls(
|
||||
status_code=src.status_code.name,
|
||||
description=src.description,
|
||||
@@ -107,17 +132,21 @@ class TraceStatus(BaseModel):
|
||||
|
||||
|
||||
class Event(BaseModel):
|
||||
"""Corresponding to opentelemetry.trace.Event"""
|
||||
"""Serializable representation of OpenTelemetry `Event` values."""
|
||||
|
||||
name: str
|
||||
"""The name of the event."""
|
||||
attributes: Attributes
|
||||
"""Mapping from attribute names to their values. Same as OpenTelemetry `Attributes` type."""
|
||||
timestamp: Optional[float] = None
|
||||
"""The timestamp of the event. Same as OpenTelemetry `Event.timestamp` type."""
|
||||
|
||||
class Config:
|
||||
allow_extra = True
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
@classmethod
|
||||
def from_opentelemetry(cls, src: OtelEvent) -> "Event":
|
||||
"""Create an [`Event`][agentlightning.Event] from an OpenTelemetry event."""
|
||||
|
||||
return cls(
|
||||
name=src.name,
|
||||
attributes=dict(src.attributes) if src.attributes else {},
|
||||
@@ -127,16 +156,19 @@ class Event(BaseModel):
|
||||
|
||||
|
||||
class Link(BaseModel):
|
||||
"""Corresponding to opentelemetry.trace.Link"""
|
||||
"""Serializable representation of OpenTelemetry `Link` values."""
|
||||
|
||||
context: SpanContext
|
||||
"""The context of the link."""
|
||||
attributes: Optional[Attributes] = None
|
||||
"""Optional attributes."""
|
||||
|
||||
class Config:
|
||||
allow_extra = True
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
@classmethod
|
||||
def from_opentelemetry(cls, src: trace_api.Link) -> "Link":
|
||||
"""Create a [`Link`][agentlightning.Link] from an OpenTelemetry link."""
|
||||
|
||||
return cls(
|
||||
context=SpanContext.from_opentelemetry(src.context),
|
||||
attributes=dict(src.attributes) if src.attributes else None,
|
||||
@@ -144,14 +176,24 @@ class Link(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class Resource(BaseModel):
|
||||
"""Corresponding to opentelemetry.sdk.resources.Resource"""
|
||||
class OtelResource(BaseModel):
|
||||
"""Serializable representation of OpenTelemetry `Resource` values.
|
||||
|
||||
Named as `OtelResource` to avoid confusion with the [`Resource`][agentlightning.Resource] class.
|
||||
Users will very rarely need to construct this class directly. Most of the times,
|
||||
they deal with the [`Resource`][agentlightning.Resource] class instead, which describes
|
||||
a very different concept.
|
||||
"""
|
||||
|
||||
attributes: Attributes
|
||||
"""Mapping from attribute names to their values. Same as OpenTelemetry `Attributes` type."""
|
||||
schema_url: str
|
||||
"""The schema URL of the resource."""
|
||||
|
||||
@classmethod
|
||||
def from_opentelemetry(cls, src: OtelResource) -> "Resource":
|
||||
def from_opentelemetry(cls, src: Resource) -> "OtelResource":
|
||||
"""Create a [`Resource`][agentlightning.Resource] from an OpenTelemetry resource."""
|
||||
|
||||
return cls(
|
||||
attributes=dict(src.attributes) if src.attributes else {},
|
||||
schema_url=src.schema_url if src.schema_url else "",
|
||||
@@ -160,35 +202,58 @@ class Resource(BaseModel):
|
||||
|
||||
|
||||
class Span(BaseModel):
|
||||
"""Agent Lightning's canonical span model used for persistence and analytics.
|
||||
|
||||
class Config:
|
||||
allow_extra = True # allow extra fields if needed
|
||||
The model captures the most relevant fields from
|
||||
`opentelemetry.sdk.trace.ReadableSpan` instances while preserving unmodeled
|
||||
attributes in Pydantic `BaseModel`'s extra storage. This keeps the serialized format
|
||||
stable even as upstream OpenTelemetry types evolve.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
rollout_id: str
|
||||
"""The rollout which this span belongs to."""
|
||||
attempt_id: str
|
||||
# The ID to make spans ordered within a single attempt
|
||||
"""The attempt which this span belongs to."""
|
||||
sequence_id: int
|
||||
"""The ID to make spans ordered within a single attempt."""
|
||||
|
||||
# Current ID (in hex, formatted via trace_api.format_*)
|
||||
trace_id: str # one rollout can have traces coming from multiple places
|
||||
"""The trace ID of the span. One rollout/attempt can have multiple traces.
|
||||
This ID comes from the OpenTelemetry trace ID generator.
|
||||
"""
|
||||
span_id: str
|
||||
"""The span ID of the span. This ID comes from the OpenTelemetry span ID generator."""
|
||||
parent_id: Optional[str]
|
||||
"""The parent span ID of the span."""
|
||||
|
||||
# Core ReadableSpan fields
|
||||
name: str
|
||||
"""The name of the span. See [OpenTelemetry docs](https://opentelemetry.io/docs/concepts/signals/traces/)."""
|
||||
status: TraceStatus
|
||||
"""The status of the span. See [OpenTelemetry docs](https://opentelemetry.io/docs/concepts/signals/traces/)."""
|
||||
attributes: Attributes
|
||||
"""The attributes of the span. See [OpenTelemetry docs](https://opentelemetry.io/docs/concepts/signals/traces/)."""
|
||||
events: List[Event]
|
||||
"""The events of the span. See [OpenTelemetry docs](https://opentelemetry.io/docs/concepts/signals/traces/)."""
|
||||
links: List[Link]
|
||||
"""The links of the span. See [OpenTelemetry docs](https://opentelemetry.io/docs/concepts/signals/traces/)."""
|
||||
|
||||
# Timestamps
|
||||
start_time: Optional[float]
|
||||
"""The start time of the span. See [OpenTelemetry docs](https://opentelemetry.io/docs/concepts/signals/traces/)."""
|
||||
end_time: Optional[float]
|
||||
"""The end time of the span. See [OpenTelemetry docs](https://opentelemetry.io/docs/concepts/signals/traces/)."""
|
||||
|
||||
# Other parsable fields
|
||||
context: Optional[SpanContext]
|
||||
"""The context of the span. See [OpenTelemetry docs](https://opentelemetry.io/docs/concepts/signals/traces/)."""
|
||||
parent: Optional[SpanContext]
|
||||
resource: Resource
|
||||
"""The parent context of the span. See [OpenTelemetry docs](https://opentelemetry.io/docs/concepts/signals/traces/)."""
|
||||
resource: OtelResource
|
||||
"""The resource of the span. See [OpenTelemetry docs](https://opentelemetry.io/docs/concepts/signals/traces/)."""
|
||||
|
||||
# Preserve other fields in the readable span as extra fields
|
||||
# Make sure that are json serializable (so no bytes, complex objects, ...)
|
||||
@@ -201,6 +266,17 @@ class Span(BaseModel):
|
||||
attempt_id: str,
|
||||
sequence_id: int,
|
||||
) -> "Span":
|
||||
"""Convert an OpenTelemetry span into the Agent Lightning data model.
|
||||
|
||||
Args:
|
||||
src: Span captured by OpenTelemetry.
|
||||
rollout_id: Identifier for the rollout that produced the span.
|
||||
attempt_id: Identifier of the attempt within the rollout.
|
||||
sequence_id: Monotonically increasing identifier assigned to the span.
|
||||
|
||||
Returns:
|
||||
Parsed [`Span`][agentlightning.Span] instance suitable for persistence.
|
||||
"""
|
||||
context = src.get_span_context()
|
||||
if context is None:
|
||||
trace_id = span_id = 0
|
||||
@@ -223,7 +299,7 @@ class Span(BaseModel):
|
||||
end_time=convert_timestamp(src.end_time),
|
||||
context=SpanContext.from_opentelemetry(context) if context else None,
|
||||
parent=(SpanContext.from_opentelemetry(src.parent) if src.parent else None),
|
||||
resource=Resource.from_opentelemetry(src.resource),
|
||||
resource=OtelResource.from_opentelemetry(src.resource),
|
||||
**extract_extra_fields(
|
||||
src,
|
||||
[
|
||||
@@ -261,8 +337,28 @@ class Span(BaseModel):
|
||||
parent_id: Optional[str] = None,
|
||||
start_time: Optional[float] = None,
|
||||
end_time: Optional[float] = None,
|
||||
resource: Optional[Resource] = None,
|
||||
resource: Optional[OtelResource] = None,
|
||||
) -> "Span":
|
||||
"""Build a synthetic span from raw attributes.
|
||||
Different from the [`from_opentelemetry`][agentlightning.Span.from_opentelemetry] method,
|
||||
all parameters other than `attributes` are optional and will be generated if not provided.
|
||||
|
||||
Args:
|
||||
attributes: Span attributes to persist.
|
||||
rollout_id: Optional rollout identifier associated with the span.
|
||||
attempt_id: Optional attempt identifier associated with the span.
|
||||
sequence_id: Optional sequence number to preserve ordering.
|
||||
name: Optional human-readable span name.
|
||||
trace_id: Custom trace identifier. When omitted, a random identifier is generated.
|
||||
span_id: Custom span identifier. When omitted, a random identifier is generated.
|
||||
parent_id: Optional parent span identifier.
|
||||
start_time: Span start timestamp in seconds.
|
||||
end_time: Span end timestamp in seconds.
|
||||
resource: Explicit resource information to attach to the span.
|
||||
|
||||
Returns:
|
||||
[`Span`][agentlightning.Span] populated with the provided attributes.
|
||||
"""
|
||||
|
||||
id_generator = RandomIdGenerator()
|
||||
trace_id = trace_id or trace_api.format_trace_id(id_generator.generate_trace_id())
|
||||
@@ -284,7 +380,7 @@ class Span(BaseModel):
|
||||
trace_state={},
|
||||
),
|
||||
name=name or SpanNames.VIRTUAL.value,
|
||||
resource=resource or Resource(attributes={}, schema_url=""),
|
||||
resource=resource or OtelResource(attributes={}, schema_url=""),
|
||||
attributes=attributes,
|
||||
status=TraceStatus(status_code="OK"),
|
||||
events=[],
|
||||
@@ -303,24 +399,34 @@ class Span(BaseModel):
|
||||
|
||||
|
||||
class SpanNames(str, Enum):
|
||||
"""Standard span name values for AgentLightning.
|
||||
|
||||
Currently reward, message, object and exception spans are supported.
|
||||
We will add more spans related to error handling in the future.
|
||||
"""
|
||||
"""Enumerated span names recognised by Agent-lightning."""
|
||||
|
||||
REWARD = "agentlightning.reward"
|
||||
"""The name of the reward span."""
|
||||
MESSAGE = "agentlightning.message"
|
||||
"""The name of the message span."""
|
||||
OBJECT = "agentlightning.object"
|
||||
"""The name of the object span."""
|
||||
EXCEPTION = "agentlightning.exception"
|
||||
"""The name of the exception span."""
|
||||
VIRTUAL = "agentlightning.virtual"
|
||||
"""The name of the virtual span. It represents derived spans without concrete operations."""
|
||||
ROLLOUT_ID = "agentlightning.rollout_id"
|
||||
"""The name of the rollout ID."""
|
||||
ATTEMPT_ID = "agentlightning.attempt_id"
|
||||
"""The name of the attempt ID."""
|
||||
SPAN_SEQUENCE_ID = "agentlightning.span_sequence_id"
|
||||
"""The name of the span sequence ID."""
|
||||
|
||||
|
||||
class SpanAttributeNames(str, Enum):
|
||||
"""Standard attribute names for AgentLightning spans."""
|
||||
"""Canonical attribute names written by Agent Lightning emitters."""
|
||||
|
||||
MESSAGE = "message"
|
||||
"""The name of the message attribute."""
|
||||
OBJECT = "object"
|
||||
"""The name of the object attribute."""
|
||||
|
||||
|
||||
SpanLike = Union[ReadableSpan, Span]
|
||||
"""Union type of OpenTelemetry `ReadableSpan` and Agent-lightning [`Span`][agentlightning.Span]."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
@@ -0,0 +1,428 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import gzip
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence, Tuple, Type, TypeVar
|
||||
|
||||
from fastapi import Request, Response
|
||||
from google.protobuf import json_format
|
||||
from google.rpc.status_pb2 import Status
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import (
|
||||
ExportLogsServiceRequest,
|
||||
ExportLogsServiceResponse,
|
||||
)
|
||||
from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import (
|
||||
ExportMetricsServiceRequest,
|
||||
ExportMetricsServiceResponse,
|
||||
)
|
||||
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
|
||||
ExportTraceServiceRequest,
|
||||
ExportTraceServiceResponse,
|
||||
)
|
||||
from opentelemetry.proto.common.v1.common_pb2 import AnyValue, KeyValue
|
||||
from opentelemetry.proto.resource.v1.resource_pb2 import Resource as ProtoResource
|
||||
from opentelemetry.proto.trace.v1.trace_pb2 import Span as ProtoSpan
|
||||
from opentelemetry.proto.trace.v1.trace_pb2 import Status as ProtoStatus
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
from opentelemetry.sdk.trace.export import SpanExportResult
|
||||
from opentelemetry.util.types import AttributeValue
|
||||
|
||||
from agentlightning.store.base import LightningStore
|
||||
from agentlightning.types.tracer import (
|
||||
Attributes,
|
||||
Event,
|
||||
Link,
|
||||
OtelResource,
|
||||
Span,
|
||||
SpanContext,
|
||||
SpanNames,
|
||||
TraceStatus,
|
||||
convert_timestamp,
|
||||
)
|
||||
|
||||
PROTOBUF_CT = "application/x-protobuf"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
T_request = TypeVar("T_request", ExportLogsServiceRequest, ExportMetricsServiceRequest, ExportTraceServiceRequest)
|
||||
T_response = TypeVar("T_response", ExportLogsServiceResponse, ExportMetricsServiceResponse, ExportTraceServiceResponse)
|
||||
|
||||
|
||||
async def handle_otlp_export(
|
||||
request: Request,
|
||||
request_message_cls: Type[T_request],
|
||||
response_message_cls: Type[T_response],
|
||||
message_callback: Optional[Callable[[T_request], Awaitable[None]]],
|
||||
signal_name: str,
|
||||
) -> Response:
|
||||
"""
|
||||
Generic handler for /v1/traces, /v1/metrics, /v1/logs.
|
||||
|
||||
Convert the OTLP Protobuf request to a JSON-like object.
|
||||
"""
|
||||
content_type = request.headers.get("Content-Type", "").split(";")[0].strip()
|
||||
|
||||
if content_type != PROTOBUF_CT:
|
||||
# For brevity we only support binary protobuf here.
|
||||
return _bad_request_response(
|
||||
request,
|
||||
f"Unsupported Content-Type '{content_type}', expected '{PROTOBUF_CT}'",
|
||||
content_type=PROTOBUF_CT,
|
||||
)
|
||||
|
||||
raw_body = await request.body()
|
||||
body = _read_body_maybe_gzip(request, raw_body)
|
||||
|
||||
# Empty request is allowed and should still succeed.
|
||||
if not body:
|
||||
req_msg = request_message_cls()
|
||||
else:
|
||||
req_msg = request_message_cls()
|
||||
try:
|
||||
req_msg.ParseFromString(body)
|
||||
except Exception as exc:
|
||||
return _bad_request_response(request, f"Unable to parse OTLP {signal_name} payload: {exc}")
|
||||
|
||||
if message_callback is not None:
|
||||
await message_callback(req_msg)
|
||||
|
||||
# Build success response. Partial success field is left unset.
|
||||
resp_msg = response_message_cls()
|
||||
|
||||
# Encode response in the same Content-Type as request.
|
||||
if content_type == PROTOBUF_CT:
|
||||
resp_bytes = resp_msg.SerializeToString()
|
||||
else:
|
||||
resp_bytes = json_format.MessageToJson(resp_msg).encode("utf-8")
|
||||
|
||||
resp_bytes, headers = _maybe_gzip_response(request, resp_bytes)
|
||||
|
||||
return Response(
|
||||
content=resp_bytes,
|
||||
media_type=content_type,
|
||||
status_code=200,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
async def spans_from_proto(request: ExportTraceServiceRequest, store: LightningStore) -> List[Span]:
|
||||
"""Parse an OTLP proto payload into List[Span].
|
||||
|
||||
A store is needed here for generating a sequence ID for each span.
|
||||
"""
|
||||
output_spans: List[Span] = []
|
||||
|
||||
for resource_spans in request.resource_spans:
|
||||
# Resource-level attributes & IDs
|
||||
resource_attrs = _kv_list_to_dict(resource_spans.resource.attributes)
|
||||
# rollout_id, attempt_id from resource attributes when present.
|
||||
rollout_id_resource = resource_attrs.get(SpanNames.ROLLOUT_ID)
|
||||
attempt_id_resource = resource_attrs.get(SpanNames.ATTEMPT_ID)
|
||||
# If sequence id is provided, all the spans will share the same sequence ID.
|
||||
# unless otherwise overridden by span-level attributes.
|
||||
sequence_id_resource = resource_attrs.get(SpanNames.SPAN_SEQUENCE_ID)
|
||||
|
||||
otel_resource = _resource_from_proto(resource_spans.resource, getattr(resource_spans, "schema_url", ""))
|
||||
|
||||
# Each ScopeSpans contains multiple spans
|
||||
for scope_spans in resource_spans.scope_spans:
|
||||
for proto_span in scope_spans.spans:
|
||||
trace_id_hex = _bytes_to_trace_id_hex(proto_span.trace_id)
|
||||
span_id_hex = _bytes_to_span_id_hex(proto_span.span_id)
|
||||
parent_id_hex = _bytes_to_span_id_hex(proto_span.parent_span_id) if proto_span.parent_span_id else None
|
||||
|
||||
# Status
|
||||
status_code_str = _STATUS_CODE_MAP.get(proto_span.status.code, "UNSET")
|
||||
status = TraceStatus(
|
||||
status_code=status_code_str,
|
||||
description=proto_span.status.message or None,
|
||||
)
|
||||
|
||||
# Attributes
|
||||
span_attrs = _kv_list_to_dict(proto_span.attributes)
|
||||
|
||||
# Context
|
||||
context = SpanContext(
|
||||
trace_id=trace_id_hex,
|
||||
span_id=span_id_hex,
|
||||
is_remote=False,
|
||||
trace_state={},
|
||||
)
|
||||
|
||||
# Try to get if span attributes contain something like rollout_id or attempt_id
|
||||
# Override the resource-level attributes with the span-level attributes if present.
|
||||
rollout_id_span = span_attrs.get(SpanNames.ROLLOUT_ID)
|
||||
attempt_id_span = span_attrs.get(SpanNames.ATTEMPT_ID)
|
||||
sequence_id_span = span_attrs.get(SpanNames.SPAN_SEQUENCE_ID)
|
||||
|
||||
# Normalize to regular strings and ints
|
||||
rollout_id_raw = rollout_id_span if rollout_id_span is not None else rollout_id_resource
|
||||
attempt_id_raw = attempt_id_span if attempt_id_span is not None else attempt_id_resource
|
||||
sequence_id_raw = sequence_id_span if sequence_id_span is not None else sequence_id_resource
|
||||
|
||||
rollout_id, attempt_id = _normalize_rollout_attempt_id(rollout_id_raw, attempt_id_raw)
|
||||
sequence_id = _normalize_sequence_id(sequence_id_raw)
|
||||
|
||||
if rollout_id is None or attempt_id is None:
|
||||
logger.warning(
|
||||
"Both rollout_id and attempt_id must be present in resource attributes. "
|
||||
"Spans will not be able to log to the store because of missing IDs: rollout_id=%s, attempt_id=%s, sequence_id=%s",
|
||||
rollout_id,
|
||||
attempt_id,
|
||||
sequence_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# Generate a new sequence ID if not provided
|
||||
if sequence_id is None:
|
||||
current_sequence_id = await store.get_next_span_sequence_id(
|
||||
rollout_id=rollout_id, attempt_id=attempt_id
|
||||
)
|
||||
else:
|
||||
current_sequence_id = sequence_id
|
||||
|
||||
# Build Span
|
||||
span = Span(
|
||||
rollout_id=rollout_id,
|
||||
attempt_id=attempt_id,
|
||||
sequence_id=current_sequence_id,
|
||||
trace_id=trace_id_hex,
|
||||
span_id=span_id_hex,
|
||||
parent_id=parent_id_hex,
|
||||
name=proto_span.name,
|
||||
status=status,
|
||||
attributes=span_attrs,
|
||||
events=_events_from_proto(proto_span),
|
||||
links=_links_from_proto(proto_span),
|
||||
start_time=convert_timestamp(proto_span.start_time_unix_nano),
|
||||
end_time=convert_timestamp(proto_span.end_time_unix_nano),
|
||||
context=context,
|
||||
parent=None, # OTLP only has parent_span_id; we don't have full SpanContext
|
||||
resource=otel_resource,
|
||||
)
|
||||
|
||||
output_spans.append(span)
|
||||
|
||||
return output_spans
|
||||
|
||||
|
||||
class LightningStoreOTLPExporter(OTLPSpanExporter):
|
||||
"""OTLP Exporter that write to a LightningStore-compatible backend.
|
||||
|
||||
The backend requires two special attributes on each span:
|
||||
|
||||
- `agentlightning.rollout_id`: The rollout ID to associate the span with.
|
||||
- `agentlightning.attempt_id`: The attempt ID to associate the span with.
|
||||
|
||||
It can optionally use the following attribute to sequence spans:
|
||||
|
||||
- `agentlightning.span_sequence_id`: A decimal string representing the sequence ID of the span.
|
||||
"""
|
||||
|
||||
_default_endpoint: Optional[str] = None
|
||||
_rollout_id: Optional[str] = None
|
||||
_attempt_id: Optional[str] = None
|
||||
|
||||
def enable_store_otlp(self, endpoint: str, rollout_id: str, attempt_id: str) -> None:
|
||||
"""Enable storing OTLP data to a specific LightningStore rollout/attempt."""
|
||||
self._rollout_id = rollout_id
|
||||
self._attempt_id = attempt_id
|
||||
|
||||
self._default_endpoint = self._endpoint
|
||||
self._endpoint = endpoint
|
||||
|
||||
def disable_store_otlp(self) -> None:
|
||||
"""Disable storing OTLP data to LightningStore."""
|
||||
self._rollout_id = None
|
||||
self._attempt_id = None
|
||||
if self._default_endpoint is not None:
|
||||
self._endpoint = self._default_endpoint
|
||||
|
||||
def should_bypass(self) -> bool:
|
||||
"""Check if the exporter should bypass the default export if rollout_id and attempt_id are not set."""
|
||||
return True
|
||||
|
||||
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
||||
if self._rollout_id is not None and self._attempt_id is not None:
|
||||
# rollout_id and attempt_id are present in resource attributes
|
||||
# It means that the server supports OTLP endpoint.
|
||||
for span in spans:
|
||||
# Override the resources so that the server knows where the request comes from.
|
||||
span._resource = span._resource.merge( # pyright: ignore[reportPrivateUsage]
|
||||
Resource.create(
|
||||
{
|
||||
SpanNames.ROLLOUT_ID: self._rollout_id,
|
||||
SpanNames.ATTEMPT_ID: self._attempt_id,
|
||||
}
|
||||
)
|
||||
)
|
||||
return super().export(spans)
|
||||
elif not self.should_bypass():
|
||||
logger.debug("Rollout ID and Attempt ID not set; using default OTLP exporter behavior.")
|
||||
return super().export(spans)
|
||||
else:
|
||||
logger.debug("Rollout ID and Attempt ID not set; bypassing export.")
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
|
||||
def _read_body_maybe_gzip(request: Request, raw_body: bytes) -> bytes:
|
||||
"""
|
||||
Decompress body if Content-Encoding: gzip; otherwise return as is.
|
||||
"""
|
||||
encoding = request.headers.get("Content-Encoding", "").lower()
|
||||
if encoding == "gzip":
|
||||
return gzip.decompress(raw_body)
|
||||
return raw_body
|
||||
|
||||
|
||||
def _maybe_gzip_response(request: Request, payload: bytes) -> Tuple[bytes, Dict[str, str]]:
|
||||
"""
|
||||
If Accept-Encoding includes gzip, gzip the payload and set Content-Encoding header.
|
||||
"""
|
||||
ae = request.headers.get("Accept-Encoding", "")
|
||||
tokens = [token.split(";")[0].strip().lower() for token in ae.split(",") if token.strip()]
|
||||
headers: Dict[str, str] = {}
|
||||
if "gzip" in tokens:
|
||||
payload = gzip.compress(payload)
|
||||
headers["Content-Encoding"] = "gzip"
|
||||
return payload, headers
|
||||
|
||||
|
||||
def _bad_request_response(request: Request, message: str, content_type: str = PROTOBUF_CT) -> Response:
|
||||
"""
|
||||
Build a 400 response whose body is a protobuf Status message, encoded
|
||||
in the same Content-Type as the request (OTLP/HTTP requirement).
|
||||
"""
|
||||
status_msg = Status(message=message)
|
||||
|
||||
if content_type == PROTOBUF_CT:
|
||||
body = status_msg.SerializeToString()
|
||||
else:
|
||||
# Fallback: JSON representation of Status.
|
||||
body = json_format.MessageToJson(status_msg).encode("utf-8")
|
||||
|
||||
body, headers = _maybe_gzip_response(request, body)
|
||||
|
||||
return Response(
|
||||
content=body,
|
||||
status_code=400,
|
||||
media_type=content_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_rollout_attempt_id(
|
||||
rollout_id: Optional[AttributeValue], attempt_id: Optional[AttributeValue]
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Normalize a rollout or attempt ID to a string."""
|
||||
rollout_id_str = str(rollout_id) if rollout_id is not None else None
|
||||
attempt_id_str = str(attempt_id) if attempt_id is not None else None
|
||||
return rollout_id_str, attempt_id_str
|
||||
|
||||
|
||||
def _normalize_sequence_id(sequence_id: Optional[AttributeValue]) -> Optional[int]:
|
||||
"""Normalize a sequence ID to an integer."""
|
||||
if sequence_id is None:
|
||||
return None
|
||||
try:
|
||||
sequence_id_int = int(str(sequence_id))
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
"Invalid sequence_id value in resource attributes: %r. Must be an integer or string representing an integer. Assuming None.",
|
||||
sequence_id,
|
||||
)
|
||||
sequence_id_int = None
|
||||
return sequence_id_int
|
||||
|
||||
|
||||
def _any_value_to_python(value: AnyValue) -> Any:
|
||||
"""Convert OTLP AnyValue -> plain Python value."""
|
||||
kind = value.WhichOneof("value")
|
||||
if kind is None:
|
||||
return None
|
||||
if kind == "string_value":
|
||||
return value.string_value
|
||||
if kind == "bool_value":
|
||||
return value.bool_value
|
||||
if kind == "int_value":
|
||||
return int(value.int_value)
|
||||
if kind == "double_value":
|
||||
return float(value.double_value)
|
||||
if kind == "array_value":
|
||||
return [_any_value_to_python(v) for v in value.array_value.values]
|
||||
if kind == "kvlist_value":
|
||||
# Map<string, AnyValue> -> dict
|
||||
return {kv.key: _any_value_to_python(kv.value) for kv in value.kvlist_value.values}
|
||||
if kind == "bytes_value":
|
||||
# Serialize bytes as hex string to stay JSON-friendly
|
||||
return value.bytes_value.hex()
|
||||
return None
|
||||
|
||||
|
||||
def _kv_list_to_dict(kvs: Sequence[KeyValue]) -> Attributes:
|
||||
"""Convert repeated KeyValue -> Attributes dict."""
|
||||
return {kv.key: _any_value_to_python(kv.value) for kv in kvs}
|
||||
|
||||
|
||||
_STATUS_CODE_MAP = {
|
||||
ProtoStatus.STATUS_CODE_UNSET: "UNSET",
|
||||
ProtoStatus.STATUS_CODE_OK: "OK",
|
||||
ProtoStatus.STATUS_CODE_ERROR: "ERROR",
|
||||
}
|
||||
|
||||
|
||||
def _bytes_to_trace_id_hex(b: bytes) -> str:
|
||||
# OTLP uses 16-byte trace IDs; format as 32-char hex
|
||||
if not b:
|
||||
return "0" * 32
|
||||
return b.hex().rjust(32, "0")
|
||||
|
||||
|
||||
def _bytes_to_span_id_hex(b: bytes) -> str:
|
||||
# OTLP uses 8-byte span IDs; format as 16-char hex
|
||||
if not b:
|
||||
return "0" * 16
|
||||
return b.hex().rjust(16, "0")
|
||||
|
||||
|
||||
def _events_from_proto(span: ProtoSpan) -> List[Event]:
|
||||
"""Event converter from OTLP ProtoSpan to List[Event]."""
|
||||
return [
|
||||
Event(
|
||||
name=e.name,
|
||||
attributes=_kv_list_to_dict(e.attributes),
|
||||
timestamp=convert_timestamp(e.time_unix_nano),
|
||||
)
|
||||
for e in span.events
|
||||
]
|
||||
|
||||
|
||||
def _links_from_proto(span: ProtoSpan) -> List[Link]:
|
||||
"""Link converter from OTLP ProtoSpan to List[Link]."""
|
||||
links: List[Link] = []
|
||||
for link in span.links:
|
||||
trace_id_hex = _bytes_to_trace_id_hex(link.trace_id)
|
||||
span_id_hex = _bytes_to_span_id_hex(link.span_id)
|
||||
ctx = SpanContext(
|
||||
trace_id=trace_id_hex,
|
||||
span_id=span_id_hex,
|
||||
is_remote=False,
|
||||
trace_state={}, # OTLP trace_state is currently a string; you can parse if needed
|
||||
)
|
||||
links.append(
|
||||
Link(
|
||||
context=ctx,
|
||||
attributes=_kv_list_to_dict(link.attributes) or None,
|
||||
)
|
||||
)
|
||||
return links
|
||||
|
||||
|
||||
def _resource_from_proto(resource: ProtoResource, schema_url: str = "") -> OtelResource:
|
||||
return OtelResource(
|
||||
attributes=_kv_list_to_dict(resource.attributes),
|
||||
schema_url=schema_url or "",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import socket
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, cast
|
||||
|
||||
import psutil
|
||||
from gpustat import GPUStat, GPUStatCollection
|
||||
|
||||
|
||||
def system_snapshot(include_gpu: bool = False) -> Dict[str, Any]:
|
||||
# CPU
|
||||
cpu = {
|
||||
"cpu_name": platform.processor(),
|
||||
"cpu_cores": psutil.cpu_count(logical=False),
|
||||
"cpu_threads": psutil.cpu_count(logical=True),
|
||||
"cpu_usage_pct": psutil.cpu_percent(0.05),
|
||||
}
|
||||
|
||||
# Memory
|
||||
vm = psutil.virtual_memory()
|
||||
mem = {
|
||||
"mem_used_gb": round(vm.used / (2**30), 2),
|
||||
"mem_total_gb": round(vm.total / (2**30), 2),
|
||||
"mem_pct": vm.percent,
|
||||
}
|
||||
|
||||
# Disk
|
||||
du = psutil.disk_usage("/")
|
||||
disk = {
|
||||
"disk_used_gb": round(du.used / (2**30), 2),
|
||||
"disk_total_gb": round(du.total / (2**30), 2),
|
||||
"disk_pct": du.percent,
|
||||
}
|
||||
|
||||
# GPU
|
||||
gpus: List[Dict[str, Any]] = []
|
||||
with suppress(Exception):
|
||||
for g in GPUStatCollection.new_query().gpus: # type: ignore
|
||||
g = cast(GPUStat, g)
|
||||
gpus.append(
|
||||
{
|
||||
"gpu": g.name, # type: ignore
|
||||
"util_pct": g.utilization,
|
||||
"mem_used_mb": g.memory_used,
|
||||
"mem_total_mb": g.memory_total,
|
||||
"temp_c": g.temperature,
|
||||
}
|
||||
)
|
||||
|
||||
# Network
|
||||
net = psutil.net_io_counters()
|
||||
netinfo = {
|
||||
"bytes_sent_mb": round(net.bytes_sent / (2**20), 2),
|
||||
"bytes_recv_mb": round(net.bytes_recv / (2**20), 2),
|
||||
}
|
||||
|
||||
# OS / meta
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||
"host": socket.gethostname(),
|
||||
"os": platform.platform(),
|
||||
**cpu,
|
||||
**mem,
|
||||
**disk,
|
||||
**netinfo,
|
||||
**({"gpus": gpus} if include_gpu else {}),
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class PatchedvLLMServer(_unwrap_ray_remote(AsyncvLLMServer)):
|
||||
async def chat_completion(self, raw_request: Request):
|
||||
"""OpenAI-compatible HTTP endpoint.
|
||||
|
||||
API reference: https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html
|
||||
API reference: [OpenAI-compatible server documentation](https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html)
|
||||
"""
|
||||
request_json = await raw_request.json()
|
||||
request = ChatCompletionRequest(**request_json)
|
||||
|
||||
@@ -7,6 +7,7 @@ import socket
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple
|
||||
|
||||
@@ -17,14 +18,12 @@ from flask import Flask, Response, abort, request
|
||||
from tensordict import TensorDict
|
||||
from verl import DataProto
|
||||
|
||||
from agentlightning import LLM, AgentLightningServer, NamedResources, RolloutLegacy, configure_logger
|
||||
from agentlightning import LLM, AgentLightningServer, NamedResources, RolloutLegacy
|
||||
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
|
||||
|
||||
configure_logger()
|
||||
|
||||
__all__ = [
|
||||
"AgentModeDaemon",
|
||||
"get_left_padded_ids_and_attention_mask",
|
||||
@@ -293,7 +292,7 @@ class AgentModeDaemon:
|
||||
self._proxy_thread.start()
|
||||
print(f"Proxy server running on port {self.proxy_port}")
|
||||
|
||||
def _update_proxy_server_v1(self):
|
||||
async def _update_proxy_server_v1(self):
|
||||
model_name = self.train_information.get("model")
|
||||
if not model_name:
|
||||
raise ValueError("Model name is not set.")
|
||||
@@ -312,12 +311,7 @@ class AgentModeDaemon:
|
||||
],
|
||||
)
|
||||
|
||||
if self.llm_proxy.is_running():
|
||||
# FIXME: Need to switch to a different port right now
|
||||
# because the forked processes carried the old fd
|
||||
self.llm_proxy.restart(_port=_find_available_port())
|
||||
else:
|
||||
self.llm_proxy.start()
|
||||
await self.llm_proxy.restart()
|
||||
|
||||
def start(self):
|
||||
"""Starts the main AgentLightningServer and the proxy server."""
|
||||
@@ -351,7 +345,7 @@ class AgentModeDaemon:
|
||||
if server_addresses != self.backend_llm_server_addresses:
|
||||
self.backend_llm_server_addresses = server_addresses
|
||||
if self.mode == "v1" and not self.llm_proxy.is_running():
|
||||
self._update_proxy_server_v1()
|
||||
await self._update_proxy_server_v1()
|
||||
self.is_train = is_train
|
||||
|
||||
# 1. Update resources on the server for clients to use
|
||||
@@ -558,33 +552,84 @@ class AgentModeDaemon:
|
||||
assert len(self._completed_rollouts_v0) == self._total_tasks_queued
|
||||
|
||||
sample_stat_list: List[Dict[str, Any]] = []
|
||||
for _, rollout in self._completed_rollouts_v0.items():
|
||||
sample_stat_list_by_source: Dict[str, List[Dict[str, Any]]] = defaultdict(
|
||||
list
|
||||
) # FIXME: Evaluate whether grouping stats by source is actually needed.
|
||||
|
||||
for rollout_id, rollout in self._completed_rollouts_v0.items():
|
||||
final_reward_raw: Optional[float] = rollout.final_reward
|
||||
final_reward = self._fillna_reward(rollout)
|
||||
if not rollout.triplets:
|
||||
print(f"Warning: No triplets found for test rollout {rollout.rollout_id}.")
|
||||
sample_stat_list.append({"reward": final_reward})
|
||||
continue
|
||||
response_length_list = [len(triplet.response.get("token_ids", [])) for triplet in rollout.triplets]
|
||||
|
||||
if "data_source" in self._task_id_to_original_sample[rollout_id]:
|
||||
# When a test sample includes a 'data_source' field, record per-source statistics for test results.
|
||||
# TODO: This is a flawed design. We should have a better way to handle this.
|
||||
data_source = self._task_id_to_original_sample[rollout_id]["data_source"]
|
||||
sample_stat_list_by_source[data_source].append(
|
||||
{
|
||||
"sum_response_length": np.sum(response_length_list),
|
||||
"mean_response_length": np.mean(response_length_list) if response_length_list else 0,
|
||||
"turn_count": len(rollout.triplets),
|
||||
"reward": final_reward,
|
||||
"has_reward": final_reward_raw is not None,
|
||||
}
|
||||
)
|
||||
sample_stat_list.append(
|
||||
{
|
||||
"sum_response_length": np.sum(response_length_list),
|
||||
"mean_response_length": np.mean(response_length_list) if response_length_list else 0,
|
||||
"turn_count": len(rollout.triplets),
|
||||
"reward": final_reward,
|
||||
"has_reward": final_reward_raw is not None,
|
||||
}
|
||||
)
|
||||
metric_dict: Dict[str, Any] = {}
|
||||
|
||||
stats_w_trace = [stat for stat in sample_stat_list if "sum_response_length" in stat]
|
||||
return {
|
||||
"val/n_rollouts": len(sample_stat_list),
|
||||
"val/n_rollouts_w_trace": len(stats_w_trace),
|
||||
"val/reward": np.mean(
|
||||
[stat["reward"] for stat in sample_stat_list]
|
||||
), # each rollout must have a reward (fillna if missing)
|
||||
"val/mean_response_length": np.mean([stat["mean_response_length"] for stat in stats_w_trace]),
|
||||
"val/sum_response_length": np.mean([stat["sum_response_length"] for stat in stats_w_trace]),
|
||||
"val/turn_count": np.mean([stat["turn_count"] for stat in stats_w_trace]),
|
||||
stats_w_trace_by_source = {
|
||||
data_source: [stat for stat in sample_stats if "sum_response_length" in stat]
|
||||
for data_source, sample_stats in sample_stat_list_by_source.items()
|
||||
}
|
||||
for data_source, sample_stats in sample_stat_list_by_source.items():
|
||||
metric_dict.update(
|
||||
{
|
||||
f"val/{data_source}/n_rollouts": len(sample_stats),
|
||||
f"val/{data_source}/n_rollouts_w_trace": len(stats_w_trace_by_source[data_source]),
|
||||
f"val/{data_source}/n_rollouts_w_reward": len(
|
||||
[stat for stat in sample_stats if stat["has_reward"]]
|
||||
),
|
||||
f"val/{data_source}/reward": np.mean(
|
||||
[stat["reward"] for stat in sample_stats]
|
||||
), # each rollout must have a reward (fillna if missing)
|
||||
f"val/{data_source}/mean_response_length": np.mean(
|
||||
[stat["mean_response_length"] for stat in stats_w_trace_by_source[data_source]]
|
||||
),
|
||||
f"val/{data_source}/sum_response_length": np.mean(
|
||||
[stat["sum_response_length"] for stat in stats_w_trace_by_source[data_source]]
|
||||
),
|
||||
f"val/{data_source}/turn_count": np.mean(
|
||||
[stat["turn_count"] for stat in stats_w_trace_by_source[data_source]]
|
||||
),
|
||||
}
|
||||
)
|
||||
metric_dict.update(
|
||||
{
|
||||
"val/n_rollouts": len(sample_stat_list),
|
||||
"val/n_rollouts_w_trace": len(stats_w_trace),
|
||||
"val/n_rollouts_w_reward": len([stat for stat in sample_stat_list if stat["has_reward"]]),
|
||||
"val/reward": np.mean(
|
||||
[stat["reward"] for stat in sample_stat_list]
|
||||
), # each rollout must have a reward (fillna if missing)
|
||||
"val/mean_response_length": np.mean([stat["mean_response_length"] for stat in stats_w_trace]),
|
||||
"val/sum_response_length": np.mean([stat["sum_response_length"] for stat in stats_w_trace]),
|
||||
"val/turn_count": np.mean([stat["turn_count"] for stat in stats_w_trace]),
|
||||
}
|
||||
)
|
||||
return metric_dict
|
||||
|
||||
def get_train_data_batch(self, max_prompt_length: int, max_response_length: int, device: torch.device):
|
||||
"""
|
||||
@@ -600,9 +645,10 @@ class AgentModeDaemon:
|
||||
# 1. Reconstruct the `finished_id_to_sample_info` structure from completed rollouts
|
||||
finished_id_to_sample_info: Dict[str, Dict[str, Any]] = {}
|
||||
finished_id_to_final_reward: Dict[str, float] = {}
|
||||
sample_with_reward_count = 0
|
||||
for rollout_id, rollout in self._completed_rollouts_v0.items():
|
||||
original_sample = self._task_id_to_original_sample[rollout_id]
|
||||
|
||||
sample_with_reward_count += int(rollout.final_reward is not None)
|
||||
final_reward = self._fillna_reward(rollout)
|
||||
|
||||
if not rollout.triplets:
|
||||
@@ -721,6 +767,7 @@ class AgentModeDaemon:
|
||||
"training/reward": np.mean(list(finished_id_to_final_reward.values())),
|
||||
"training/n_rollouts": len(finished_id_to_final_reward),
|
||||
"training/n_rollouts_w_trace": len(finished_id_to_sample_info),
|
||||
"training/n_rollouts_w_reward": sample_with_reward_count,
|
||||
"training/n_truncated_triplets": n_trunc_sample_because_of_response,
|
||||
"training/n_triplets": n_transition,
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
# type: ignore
|
||||
|
||||
from importlib.metadata import version
|
||||
from typing import Any
|
||||
|
||||
import hydra
|
||||
import ray
|
||||
from packaging import version as packaging_version
|
||||
from verl.trainer.main_ppo import create_rl_sampler
|
||||
from verl.trainer.ppo.reward import load_reward_manager
|
||||
|
||||
@@ -39,11 +41,17 @@ def run_ppo(
|
||||
) -> None:
|
||||
if not ray.is_initialized():
|
||||
# this is for local ray cluster
|
||||
try:
|
||||
# verl >= 0.6.0
|
||||
num_cpus = config.ray_kwargs.ray_init.num_cpus
|
||||
except AttributeError:
|
||||
# verl < 0.6.0
|
||||
num_cpus = config.ray_init.num_cpus
|
||||
ray.init(
|
||||
runtime_env={
|
||||
"env_vars": {"TOKENIZERS_PARALLELISM": "true", "NCCL_DEBUG": "WARN", "VLLM_LOGGING_LEVEL": "WARN"}
|
||||
},
|
||||
num_cpus=config.ray_init.num_cpus,
|
||||
num_cpus=num_cpus,
|
||||
)
|
||||
|
||||
runner = TaskRunner.remote()
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Dict, Tuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import verl
|
||||
from codetiming import Timer
|
||||
from omegaconf import OmegaConf
|
||||
from tqdm import tqdm
|
||||
@@ -19,7 +20,7 @@ from verl import DataProto
|
||||
from verl.protocol import pad_dataproto_to_divisor, unpad_dataproto
|
||||
from verl.trainer.ppo.core_algos import agg_loss
|
||||
from verl.trainer.ppo.metric_utils import (
|
||||
compute_data_metrics,
|
||||
_compute_response_info,
|
||||
compute_throughout_metrics,
|
||||
compute_timing_metrics,
|
||||
)
|
||||
@@ -53,6 +54,108 @@ def _timer(name: str, timing_raw: Dict[str, float]):
|
||||
timing_raw[name] += timer.last
|
||||
|
||||
|
||||
# This function is adapted from verl.
|
||||
# We introduce a new parameter `suffix` to distinguish between metrics computed
|
||||
# before and after AgentLightning’s post-processing.
|
||||
# - "Before" refers to raw reward and advantage values.
|
||||
# - "After" refers to values computed following post-processing, which involves:
|
||||
# (1) Dropping prompts that exceed the maximum allowed length.
|
||||
# (2) Adjusting the batch size to be a multiple of the mini PPO size.
|
||||
# Different suffixes are used to label these two stages accordingly.
|
||||
def compute_data_metrics(batch: DataProto, use_critic: bool = True, suffix: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
Computes various metrics from a batch of data for PPO training.
|
||||
|
||||
This function calculates metrics related to scores, rewards, advantages, returns, values,
|
||||
and sequence lengths from a batch of data. It provides statistical information (mean, max, min)
|
||||
for each metric category.
|
||||
|
||||
Args:
|
||||
batch: A DataProto object containing batch data with token-level scores, rewards, advantages, etc.
|
||||
use_critic: Whether to include critic-specific metrics. Defaults to True.
|
||||
|
||||
Returns:
|
||||
A dictionary of metrics including:
|
||||
- critic/score/mean, max, min: Statistics about sequence scores
|
||||
- critic/rewards/mean, max, min: Statistics about sequence rewards
|
||||
- critic/advantages/mean, max, min: Statistics about advantages
|
||||
- critic/returns/mean, max, min: Statistics about returns
|
||||
- critic/values/mean, max, min: Statistics about critic values (if use_critic=True)
|
||||
- critic/vf_explained_var: Explained variance of the value function (if use_critic=True)
|
||||
- response_length/mean, max, min, clip_ratio: Statistics about response lengths
|
||||
- prompt_length/mean, max, min, clip_ratio: Statistics about prompt lengths
|
||||
"""
|
||||
sequence_score = batch.batch["token_level_scores"].sum(-1)
|
||||
sequence_reward = batch.batch["token_level_rewards"].sum(-1)
|
||||
|
||||
advantages = batch.batch["advantages"]
|
||||
returns = batch.batch["returns"]
|
||||
|
||||
max_response_length = batch.batch["responses"].shape[-1]
|
||||
|
||||
prompt_mask = batch.batch["attention_mask"][:, :-max_response_length].bool()
|
||||
response_mask = batch.batch["attention_mask"][:, -max_response_length:].bool()
|
||||
|
||||
max_prompt_length = prompt_mask.size(-1)
|
||||
|
||||
response_info = _compute_response_info(batch)
|
||||
prompt_length = response_info["prompt_length"]
|
||||
response_length = response_info["response_length"]
|
||||
|
||||
valid_adv = torch.masked_select(advantages, response_mask)
|
||||
valid_returns = torch.masked_select(returns, response_mask)
|
||||
|
||||
if use_critic:
|
||||
values = batch.batch["values"]
|
||||
valid_values = torch.masked_select(values, response_mask)
|
||||
return_diff_var = torch.var(valid_returns - valid_values)
|
||||
return_var = torch.var(valid_returns)
|
||||
|
||||
metrics = {
|
||||
# score
|
||||
"critic/score/mean" + suffix: torch.mean(sequence_score).detach().item(),
|
||||
"critic/score/max" + suffix: torch.max(sequence_score).detach().item(),
|
||||
"critic/score/min" + suffix: torch.min(sequence_score).detach().item(),
|
||||
# reward
|
||||
"critic/rewards/mean" + suffix: torch.mean(sequence_reward).detach().item(),
|
||||
"critic/rewards/max" + suffix: torch.max(sequence_reward).detach().item(),
|
||||
"critic/rewards/min" + suffix: torch.min(sequence_reward).detach().item(),
|
||||
# adv
|
||||
"critic/advantages/mean" + suffix: torch.mean(valid_adv).detach().item(),
|
||||
"critic/advantages/max" + suffix: torch.max(valid_adv).detach().item(),
|
||||
"critic/advantages/min" + suffix: torch.min(valid_adv).detach().item(),
|
||||
# returns
|
||||
"critic/returns/mean" + suffix: torch.mean(valid_returns).detach().item(),
|
||||
"critic/returns/max" + suffix: torch.max(valid_returns).detach().item(),
|
||||
"critic/returns/min" + suffix: torch.min(valid_returns).detach().item(),
|
||||
**(
|
||||
{
|
||||
# values
|
||||
"critic/values/mean" + suffix: torch.mean(valid_values).detach().item(),
|
||||
"critic/values/max" + suffix: torch.max(valid_values).detach().item(),
|
||||
"critic/values/min" + suffix: torch.min(valid_values).detach().item(),
|
||||
# vf explained var
|
||||
"critic/vf_explained_var" + suffix: (1.0 - return_diff_var / (return_var + 1e-5)).detach().item(),
|
||||
}
|
||||
if use_critic
|
||||
else {}
|
||||
),
|
||||
# response length
|
||||
"response_length/mean" + suffix: torch.mean(response_length).detach().item(),
|
||||
"response_length/max" + suffix: torch.max(response_length).detach().item(),
|
||||
"response_length/min" + suffix: torch.min(response_length).detach().item(),
|
||||
"response_length/clip_ratio"
|
||||
+ suffix: torch.mean(torch.eq(response_length, max_response_length).float()).detach().item(),
|
||||
# prompt length
|
||||
"prompt_length/mean" + suffix: torch.mean(prompt_length).detach().item(),
|
||||
"prompt_length/max" + suffix: torch.max(prompt_length).detach().item(),
|
||||
"prompt_length/min" + suffix: torch.min(prompt_length).detach().item(),
|
||||
"prompt_length/clip_ratio"
|
||||
+ suffix: torch.mean(torch.eq(prompt_length, max_prompt_length).float()).detach().item(),
|
||||
}
|
||||
return metrics
|
||||
|
||||
|
||||
class AgentLightningTrainer(RayPPOTrainer):
|
||||
"""
|
||||
Specialized PPO trainer for agent-based reinforcement learning.
|
||||
@@ -63,6 +166,7 @@ class AgentLightningTrainer(RayPPOTrainer):
|
||||
RayPPOTrainer and focusing on the agent mode workflow.
|
||||
|
||||
Key differences from RayPPOTrainer:
|
||||
|
||||
1. Uses AgentModeDaemon for server communication
|
||||
2. Simplified data flow without pop/union operations
|
||||
3. Direct batch processing through agent daemon
|
||||
@@ -214,6 +318,9 @@ class AgentLightningTrainer(RayPPOTrainer):
|
||||
config=self.config.algorithm,
|
||||
)
|
||||
|
||||
# Calculate the metrics before processing. Refer to the comments of function `compute_data_metrics` for details.
|
||||
metrics.update(compute_data_metrics(batch=batch, use_critic=self.use_critic, suffix="_before_processing"))
|
||||
|
||||
# after advantages are assinged, we begin to drop (1) long prompt (2) floor to ppo minisize
|
||||
keep_indices = (~batch.batch["is_drop_mask"]).nonzero(as_tuple=True)[0]
|
||||
metrics["training/n_triplets_prompt_too_long"] = (
|
||||
@@ -273,7 +380,7 @@ class AgentLightningTrainer(RayPPOTrainer):
|
||||
)
|
||||
|
||||
# compute training metrics
|
||||
metrics.update(compute_data_metrics(batch=batch, use_critic=self.use_critic))
|
||||
metrics.update(compute_data_metrics(batch=batch, use_critic=self.use_critic, suffix="_after_processing"))
|
||||
metrics.update(compute_timing_metrics(batch=batch, timing_raw=timing_raw))
|
||||
# TODO: implement actual tflpo and theoretical tflpo
|
||||
n_gpus = self.resource_pool_manager.get_n_gpus()
|
||||
@@ -297,14 +404,20 @@ class AgentLightningTrainer(RayPPOTrainer):
|
||||
assert self.async_rollout_mode, "If agent mode is enabled, async server must be enabled"
|
||||
if self.adapter is not None and not isinstance(self.adapter, TraceToTripletBase):
|
||||
raise ValueError("Adapter must be a TraceToTripletBase for currently VERL implementation.")
|
||||
verl_version = verl.__version__
|
||||
if verl_version == "0.5.0":
|
||||
# Note (Zhiyuan): To avoid further patch into vllm async server, using the same sentence to get the naming here.
|
||||
# However, it is possible that verl updates the naming and causes incompatibility.
|
||||
# Reference: https://github.com/volcengine/verl/blob/5b5e09d9cc20625e436d01f69d9cc739ff681c54/verl/workers/rollout/vllm_rollout/vllm_async_server.py#L217
|
||||
model = "/".join(self.config.actor_rollout_ref.model.path.split("/")[-2:])
|
||||
else:
|
||||
# For other versions (e.g., 0.6.0), we use the full path to the model.
|
||||
model = self.config.actor_rollout_ref.model.path
|
||||
self.agent_mode_daemon = AgentModeDaemon(
|
||||
self.config.agentlightning.port,
|
||||
self.config.actor_rollout_ref.rollout.n,
|
||||
train_information={
|
||||
# Note (Zhiyuan): To avoid further patch into vllm async server, using the same sentence to get the naming here.
|
||||
# However, it is possible that verl updates the naming and causes incompatibility.
|
||||
# Reference: https://github.com/volcengine/verl/blob/5b5e09d9cc20625e436d01f69d9cc739ff681c54/verl/workers/rollout/vllm_rollout/vllm_async_server.py#L217
|
||||
"model": "/".join(self.config.actor_rollout_ref.model.path.split("/")[-2:]),
|
||||
"model": model,
|
||||
"temperature": self.config.actor_rollout_ref.rollout.temperature,
|
||||
},
|
||||
tokenizer=self.tokenizer,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user