Compare commits

...

403 Commits

Author SHA1 Message Date
Yuge Zhang c632d8dd80 Restore Python lint checks and extend them to the verl subtree (#553)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 10:08:06 +08:00
Yuge Zhang 07c4ce894d Correct the release skill's tag-trigger model and commands (#550)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:30:00 +08:00
Yuge Zhang 4123f0c3bf Validate every skill, including .agents/skills, in CI (#551)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 16:58:38 +08:00
Yuge Zhang 30e675906f Add release skill (#549) 2026-08-21 15:45:18 +08:00
Yuge Zhang 5415d850af Migrate release and test workflows for v1 (#548) 2026-08-21 10:31:28 +08:00
Dan Fiedler 8e22ca1902 Pin GitHub Actions to full-length commit SHAs (#546) 2026-08-20 12:42:33 +08:00
Zhiyuan He bd80905120 Docs/add v1 technical report links (#545)
Co-authored-by: GitHub Actions <actions@github.com>
2026-08-19 12:24:04 +08:00
dalongbao 352f1bd7c1 docs: simplify benchmark results (#540)
Co-authored-by: dalongbao <v-tinyantsui@microsoft.com>
2026-08-17 16:29:46 +08:00
Zhiyuan He 2914f3e2ef Docs/update readme stable docs link (#544)
Co-authored-by: GitHub Actions <actions@github.com>
2026-08-17 15:02:48 +08:00
Copilot 5e9c711724 Fix docs deploy dependency setup (#543) 2026-08-17 12:54:17 +08:00
Zhiyuan He 8f8b8f95fd Merge pull request #539 from microsoft/dev/v1.0.0
Upgrade to v1.0.0
2026-08-17 12:37:06 +08:00
hzy46 0eb4af11f9 docs: update architecture diagram 2026-08-17 12:35:33 +08:00
dalongbao d2c4d1f630 feat: agl-skill (#534)
Co-authored-by: dalongbao <v-tinyantsui@microsoft.com>
2026-08-14 12:47:18 +08:00
hzy46 d1d6895782 docs: update legacy branch links 2026-08-13 12:27:35 +08:00
hzy46 5361ade1e5 docs: restore security policy 2026-08-12 17:16:38 +08:00
hzy46 b2ca3a50af docs: update documentation for v1.0 2026-08-12 17:10:22 +08:00
hzy46 5b6fd360f2 Merge remote-tracking branch 'agl-lite/main' into dev/v1.0.0 2026-08-12 15:46:28 +08:00
hzy46 f033bb2cb8 chore: remove legacy implementation and documentation 2026-08-12 15:46:12 +08:00
Zhiyuan He 68cef03c03 Rename package to Agent Lightning (#50)
* Rename package to Agent Lightning

* Keep package initialization minimal
2026-08-12 14:27:23 +08:00
Zhiyuan He b81c9b8e69 docs: add documentation index (#49) 2026-08-11 18:34:41 +08:00
Zhiyuan He ea4e49993d Docs, License, Copyright (#48)
* docs: add agl-lite documentation

* docs: refine project messaging

* docs: refresh project overview and examples

* Update README.md

* docs: adopt Agent Lightning v1.0 branding

* Update README.md

* docs & license

* Tune SWE-smith turn penalty

* Reorganize example documentation

* Refine README tagline

* docs: reorganize setup and configuration guides

* docs: clarify shared gateway key

* docs: simplify configuration and async training guides
2026-08-11 18:30:09 +08:00
Ldemon df20382d21 feat: add rollout-level mean policy loss (#47)
Register a VERL policy loss that gives each rollout equal weight across its expanded training rows. Normalize advantages by rollout token count and trained batch size, and load the custom loss in Ray actor workers.

Add focused coverage for registration, normalization, PPO aggregation, and input validation.
2026-08-11 11:03:59 +08:00
Zhiyuan He 5b7b4bb7cc refactor: simplify rollout integration code (#46) 2026-08-10 14:57:58 +08:00
Zhiyuan He 4a052204fa Fix no-batch rollout replica sleep (#45) 2026-07-16 21:24:28 +08:00
Ldemon 4a610aee27 feat(async_swe_smith): online GRPO training example for SWE-smith (#34)
* example/async_swe_smith: online GRPO training example for SWE-smith

Add an end-to-end async GRPO example that trains a SWE agent on the
SWE-smith dataset via agl-lite, plus supporting infrastructure:

- examples/async_swe_smith: agent, chat template, k8s job template,
  image pre-pull, FSDP + Megatron trainers, Qwen3-30B-A3B / Qwen3.5-9B
  launch scripts, README, and a smoke test.
- agl_lite/verl/trainer.py: per-step GRPO group / zero-advantage group
  counts and a per-stage perf/mfu/actor_infer metric.
- examples/calc_x/comparison: async-vs-sync runner scripts.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* feat(verl): record per-rollout pod lifecycle timing

Capture server-authoritative running_at/finished_at while polling and
emit, per training step, a wandb Table of per-rollout submitted/running/
finished timestamps plus queue-wait/run-duration scalar aggregates. The
queue wait (running_at - submitted) exposes how long pods sit waiting
when jobs are launched in CPU-limited batches.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* fix(setup_verl): install matching CUDA 13.0 toolchain for cu130 flash-attn build

The cu130 variant builds flash-attn from source against torch's CUDA 13.0
runtime, but the system CUDA toolkit is often 12.x, so nvcc rejects the
build. Install a CUDA 13.0 pip toolchain (nvcc/crt/nvvm/cccl/runtime) pinned
to >=13.0,<13.1 and point the build at it via CUDA_HOME/PATH/LIBRARY_PATH/
LD_LIBRARY_PATH/CPATH. Pinning to 13.0.x keeps nvcc's version aligned with
torch's CUDART (13000); a mismatched minor (e.g. 13.3) trips cccl's
"CUDA compiler and CUDA toolkit headers are incompatible" check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* smaller cpu request

* feat(async_swe_smith): add Qwen3-8B sync-rollout trainer launch script

Mirrors the async async_swe_smith_qwen3_8b run (wandb fxaj7bcw) but flips
agentlightning.async_rollout.enabled=False for synchronous rollouts, keeping
all other hyperparameters identical (TP=2, GRPO n=8, train_batch_size=32,
ppo_micro_batch_size_per_gpu=2, lr=1e-6, clip 0.2/0.28, max_model_len=32768).
rollout.mode stays async (vLLM OpenAI server mode for tool calling). Sizing
knobs are env-overridable (AGL_GPU_MEM_UTIL, AGL_ROLLOUT_TP, etc.).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* example/async_swe_smith: add Qwen3-8B async runner; enable chunked prefill

Add run_qwen3_8b_async.sh (async counterpart of run_qwen3_8b_sync.sh):
identical training hyperparameters, only agentlightning.async_rollout.
enabled=True, for the sync-vs-async speedup A/B. Also flip the example's
vLLM enable_chunked_prefill to True.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* example/async_swe_smith: lower agent pod memory limit to 2Gi

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* example/async_swe_smith: expose train/async batch sizes as env-overridable knobs

Surface data.train_batch_size (32) and async_rollout.async_train_batch_size
(48) in run_qwen3_8b_async.sh via AGL_TRAIN_BATCH_SIZE / AGL_ASYNC_TRAIN_BATCH_SIZE,
passed through SIZING_OVERRIDES. Also bump default total_epochs 2 -> 4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* example/async_swe_smith: disable chunked prefill + val-before-train; add requirements.txt

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* update config

* smaller poll interval for less cpu pressure

* example/async_swe_smith: auto-create AGL_NAMESPACE before controller start

The controller does not create its target namespace; with a non-default
AGL_NAMESPACE the ConfigMap step would fail on a missing namespace. Ensure
it idempotently before launch so per-user namespace isolation works.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* example/async_swe_smith: add openai-preinstalled job template variant

Uses the :openai SWE-smith images (openai library baked in) and drops the runtime 'pip install openai', removing the per-rollout cold-start cost.

* async_swe_smith: use openai-preinstalled job template for training

Point the async trainer at job-template-openai.yaml (":openai" images
with the openai library prebuilt) so rollout pods skip the runtime
pip install openai and start the agent directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* example/async_swe_smith: add Qwen3-30B-A3B Megatron/R3 async runner

Conservative single-node 4x B200 defaults (n=4, gpu_mem_util=0.6,
train_batch=16, full param/optim/grad offload + recompute) sized to fit
30B-A3B; all knobs env-overridable. Set vLLM moe_backend=triton.

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: support explicit pre-split train/val datasets

Add --train-dataset-path / --val-dataset-path (default to
train_datasets.jsonl / val_datasets.jsonl) so the trainer consumes
pre-split, pre-curated datasets as-is: no FAIL_TO_PASS curation and no
train/val split. Falls back to the legacy single-file + split path when
those files are absent. Adds --max-val-instances to bound validation
eval time.

Large local datasets (subset0 / train / val .jsonl) are excluded via
.git/info/exclude rather than .gitignore, so this drops the tracked
subset0 ignore line.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* async_swe_smith: support explicit pre-split train/val datasets in megatron entrypoint

Mirror the load_split_file path from train_smith_agent.py (27ec14d) into the
Megatron+R3 entrypoint: add --train-dataset-path/--val-dataset-path/
--max-val-instances and consume the pre-split files as-is (no FAIL_TO_PASS
curation, no internal train/val split) when both exist, else fall back to the
single-file split. Also add --ci so the run script's smoke flag is a real arg
instead of leaking into the hydra config overrides.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* async_swe_smith: add rollout correction (IS/RS) knobs to megatron runner

Wire algorithm.rollout_correction.* (bypass_mode / rollout_is / rollout_rs
and thresholds) into the Qwen3-30B-A3B megatron runner as env-overridable
sizing overrides, mirroring uni-agent's train_qwen3_moe_rc.sh defaults.
loss_mode is left at the actor default (verl 0.8.0 has no gspo).

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: match fsdp rollout sizing in megatron runner

Bump default ROLLOUT_N 4->8, TRAIN_BATCH_SIZE 16->32, ASYNC_TRAIN_BATCH_SIZE
24->48 to mirror the fsdp distributed run's rollout workload, enabling an
apples-to-apples engine comparison at equal generation volume.

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: standardize wandb experiment naming

Name runs as swe_smith_{sync|async}_{model}_{backend} so FSDP and Megatron
runs are distinguishable in wandb. Mode is read from rollout.mode, model
from the basename of the model path; backend is fsdp / megatron per script.

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: add pure-rollout collection harness

Collect reward (all-0/all-1 GRPO degeneration), completion-token length, and
turn-count stats over the train/val datasets without running a VERL trainer.

- serve_vllm_qwen3_8b.sh: stand up Qwen3-8B vLLM (128K ctx, chunked prefill,
  TP=1, CUDA graph, hermes tools, return-tokens-as-token-ids) and register it
  with the server proxy via POST /api/models.
- enqueue_rollouts.py: POST each dataset row N times (GRPO group) to
  /api/rollouts with the openai job template + is_train flag.
- rollout_stats.py: poll finished rollouts, aggregate from reward/model_request/
  agent_output events, group by data_id, report train/val separately.
- job-template-openai.yaml: SMITH_MAX_TURNS=1000 so turns (not the 40-cap) bound
  self-completion.
- rollout_test.md: design doc.

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: retry reward post until it succeeds

Reward events are training signal, so a single timeout silently dropped
the sample. Retry with capped exponential backoff until the post lands.

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: full-run coverage for pure-rollout stats harness

The rollout_stats collector previously polled GET /api/rollouts?state_in=
succeeded&failed&limit=10000. That list route returns matches[:limit] in
insertion order with no offset, and each rollout is ~77KB (job_template is
embedded), so for the 164k-rollout SWE-smith run stats would silently plateau
at ~10k terminal rollouts (~6% of the run) — never seeing later completions.

Server: add an append-only completion log (_terminal_order, appended on
terminal state transitions in patch_rollout) and a cursor-paginated, lightweight
projection endpoint GET /api/rollouts/terminal?after=&limit= returning only
rollout_id/state/data_id/is_train + a next_after cursor. Because the log is
ordered by completion, an index cursor never misses out-of-order completions
and needs no full rescan of the store.

Collector: drain the cursor instead of re-listing; O(page_size) work per poll,
no 10k cap. Atomic snapshot writes (tmp + os.replace), per-rollout event-fetch
retry that skips unreadable rollouts without stalling the cursor, and a
--page-size flag. Snapshot now reports processed/total_terminal/backlog.

serve_vllm_qwen3_8b.sh: Qwen3-8B native ctx is 40960 and vLLM 0.20.2 dropped
--rope-scaling, so --max-model-len 131072 was rejected outright. Add YaRN via
--hf-overrides (factor 4.0 over original 32768) when extending beyond native
context — the documented 128K goal now actually serves.

Tests: cover the new endpoint (completion-order pagination, cursor advance,
projection fields, non-terminal exclusion); reset _terminal_order between tests.
.gitignore: ignore the generated rollout_stats.json output.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* async_swe_smith: checkout bug branch so the agent sees the injected bug

The SWE-smith image defaults /testbed to clean `main`, so the agent saw fixed
code, had nothing to fix, and evaluate() false-passed (reward 1.0 on a 0-byte
patch). Now the agent checks out the bug branch HEAD (`Remove F2P Tests`):
buggy source with the FAIL_TO_PASS tests removed, so it cannot read the tests
to reverse the fix. At evaluation time the F2P test files are restored from the
parent `Bug Patch` commit, keeping the agent's edits, so the real
FAIL_TO_PASS/PASS_TO_PASS suite runs against the fix.

Co-Authored-By: Claude <noreply@anthropic.com>

* Align SWE-smith agent loop with mini-swe-agent

Replace OpenAI tool-calling with mini-swe-agent's text/bash action space:
- one bash code block per turn (parse_action); submit via the
  COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT marker instead of a submit tool
- swebench-style system/instance prompts adapted for /testbed
- head/tail observation elision + format-error nudges (max_format_errors)
- execute commands via 'bash -c' with pagers/progress bars silenced

Keeps AGL glue (events, eval-meta, bug-commit checkout, F2P restore,
git-diff patch capture, FAIL_TO_PASS/PASS_TO_PASS eval) and the
context-overflow / transient-error loop contracts. Adds SMITH_MAX_FORMAT_ERRORS.

* async_swe_smith: distinguish eval timeout from real failure + rollout tooling

- smith_agent: evaluate() now flags eval_timeout (pytest rc=124 / "timed out"),
  surfaced in the reward event; fix p2p_ok counting None status as PASSED (which
  let timeouts false-pass as resolved). Set temperature=1.0 explicitly; cap
  SMITH_MAX_TURNS at 100 in job-template-openai.
- rollout_stats: tolerate transient connection errors (RemoteDisconnected).
- add monitor_controller.sh (periodic health probe), trace.md (4-rollout trace
  example), reward_table.md (per-instance reward/patch snapshot).

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: add SWE-smith --f2p_only eval mode (on by default)

Mirror swesmith/harness/grading.py: when f2p_only, restrict evaluation to the
test files containing FAIL_TO_PASS. F2P is kept whole; PASS_TO_PASS is filtered
to only the P2P tests in those same files — same-file regressions still count,
but the many unrelated cross-file P2P tests (the eval-time/timeout driver) are
dropped. Resolution still needs F2P AND filtered P2P to pass. Toggled by
SMITH_F2P_ONLY (default on).

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: add rollout-collection runbook + full f2p_only result table

- plan.md: end-to-end runbook for launching a local pure-rollout collection
  batch (cold-start vLLM/server/controller, enqueue, monitor, gold-patch
  comparison), with the known pitfalls and how to run the second half.
- reward_table.md: full 367-instance agent-vs-gold patch comparison under
  official f2p_only (all-0 98.1%, eval timeouts 0/1468).

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: lightweight branch switch to avoid checkout timeouts

git checkout --force <branch> stats/rewrites the whole /testbed working tree
(thousands of files for repos like pandas), which times out under high pod
concurrency (node at 6x oversubscription) and fails jobs. Instead, diff
main..branch at the object level to get just the changed files and apply only
those: modified/added paths via checkout, deleted F2P tests via git rm. All
git calls now go through a retry helper. Measured 1234ms -> 32ms on pandas;
working-tree state is identical to the old full checkout.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(swe-smith): Qwen3.5-9B pure-rollout eval + 8B vs 9B reward table

- 4x vLLM Qwen3.5-9B (TP=1, round-robin) pure-rollout over subset0
- gen_compare_table.py / gen_reward_table_9b.py reward-table builders
- reward_table.md: 8B(all-0 98%) vs 9B(~47%), 116/230 improved
- baseline 8B table snapshot; trainer wandb table trimmed; async sizing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* swe-smith: per-call max_tokens 16384, vLLM ctx 128k for 9B rollout

- smith_agent default AGL_MAX_TOKENS 4096->16384
- job-template-openai: AGL_MAX_TOKENS=16384 env
- vLLM restarted max_model_len 131072 (4x, registered)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix shuffling issue & zero sample issue

* async_swe_smith: align reward with official XFAIL grading + 128k train ctx

Count XFAIL as pass (F2P/P2P) to match swesmith/harness/grading.py; set
data/trajectory max lengths to 65536 = 128k. Add event-driven fail/starve
pod watcher.

Co-Authored-By: Claude <noreply@anthropic.com>

* swe-smith: reward_table 9B 321/367, add per-rollout total token length

- gen_compare_table: 9B total-len column (avg) + p50/p90/max summary
- 9B vs 8B: all-0 98%->52%, improved 147, regress 0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* swe-smith: annotate units in reward_table (patch=bytes, total=tokens)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* async_swe_smith: tee rollout pod logs to hostPath for post-mortem

Mount host /agl-logs and tee stdout/stderr to agl-rollout-<id>.log so a
killed/evicted pod's trace survives the TTL reaper. Both templates.

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: bump pod memory to 4Gi to stop OOMKills

Long 128k-ctx trajectories were killed at 2Gi. Raise request+limit to 4Gi.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: promote async SWE-smith example

Rename async_swe_smith to swe_smith, update startup docs/scripts, and prepare local :openai rollout images from minikube-loaded tags.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: log trace merge mismatches to wandb

Record capped unmerged trajectory triplets as a wandb table and expose mismatch row metrics from the rollout adapter.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* async_swe_smith: add delete.jsonl — instances with missing bug branches

35 instances across 11 repos (mostly Go) whose images lack the
origin/<instance_id> branch, so checkout fails with "unknown revision"
and the rollout dies before turn 1. Listed for dataset filtering.

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: wait out gateway pause instead of burning a turn

On 429 'gateway paused' (async weight sync), the agent now retries in place
every 5s up to SMITH_GATEWAY_WAIT_S (default 600s) without incrementing the
turn or appending an empty assistant message. Previously the pause was
treated as a generic error, burning turns and polluting the trajectory.

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: harden fetch_eval_meta against transient server errors

RemoteDisconnected escaped the except (it is http.client.HTTPException, not
URLError) and crashed the agent; timeouts were swallowed but left instance_id
empty, wasting a full rollout until checkout failed. Catch connection-reset /
HTTP exceptions, retry 4x with backoff for the bursty transient failures, and
abort immediately when no instance_id can be fetched instead of running on.

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: force pytest serial in eval to stop OOMKills

The eval pytest command passed no -n flag, so a testbed project's own config
(e.g. scipy's `-n auto`) spawned workers = HOST core count (dozens), ignoring
the pod's cpu:1 / 4Gi cgroup. ~70 python workers @ ~112MB each exhausted the
4Gi limit and triggered cgroup OOMKill mid-rollout — verified live: OOMed pods
had 70+ python procs, memory.current pinned at 4094/4096Mi. Append `-p no:xdist`
to disable the plugin (overrides any ini `-n auto`; no-op when xdist absent).
Confirmed fixed: new pods now run 1 python proc instead of 70.

Co-Authored-By: Claude <noreply@anthropic.com>

* async_swe_smith: update reward_table.md — Qwen3.5-9B pure-rollout (620 instances)

Snapshot of the in-progress Qwen3.5-9B pure-rollout collection over
train_datasets.jsonl (is_train=false, n=4 GRPO groups), throttled to 50
in-flight. 620 instances / 2490 rollouts terminal so far; all-0 544, all-1 51,
mixed 25; 0 eval timeouts (SMITH_EVAL_TIMEOUT=3000, SMITH_MAX_TURNS=100).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* async_swe_smith: cap pytest at -n4, bump pod mem 6Gi, throttle job rate

Eval-time OOMKills traced to testbed `-n auto` reading host cores (~70 xdist
workers) and busting the cgroup. Pin -n4 (~450MB) with serial fallback when
xdist is absent. Raise pod memory to 6Gi and lower max_jobs_per_minute to 100.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: harden SWE-smith rollout checkout

* docs: document SWE-smith Qwen3.5 sync run

* swe_smith: point hostPath logs at host dir after kubeadm migration

minikube is replaced by a kubeadm single-node cluster, so pods run directly
on host docker. hostPath /agl-logs (minikube VM path) now points at the real
host dir /home/v-zhiwenzhou/agl-logs — no minikube mount needed. Also lower
openai template pod memory to 4Gi.

Co-Authored-By: Claude <noreply@anthropic.com>

* swe_smith: update reward_table.md — Qwen3.5-9B pure-rollout (7300 instances)

Snapshot of the Qwen3.5-9B pure-rollout collection over the verified + full
train datasets (is_train=false, n=4 GRPO groups), throttled to 50 in-flight,
deduped by data_id. 7300 instances / 29174 rollouts: all-0 4510 (61.8%),
all-1 1667 (22.8%), mixed 1123 (15.4%); 50 eval timeouts
(SMITH_EVAL_TIMEOUT=3000, SMITH_MAX_TURNS=100).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* swe_smith: store reward table as SQLite db, drop markdown

Switch the reward table from reward_table.md to reward_table.db (SQLite).
Column names match the old markdown detail table exactly: instance, rollouts,
"r=0", "r=1", "分类", "best patch_size", "avg turns", "超时".

gen_reward_db.py is incremental — it caches each terminal rollout by id in a
rollout_events table and only fetches events for NEW rollouts, so a refresh
takes ~7s instead of ~10min at 30k+ rollouts. reward_table is rebuilt from
rollout_events (is_train=0, grouped by data_id).

Snapshot: 30905 rollouts / 7718 instances (all-0 4606, all-1 1893, mixed 1219).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* swe_smith: retry idempotent /proxy/pause with 5min timeout; pod mem 6Gi

/proxy/pause only sets state.paused=True (idempotent, lock-guarded), so retrying
is safe. When the server is saturated with in-flight rollouts the pause POST can
be slow to get a connection and the default 30s timeout is not enough — give it
300s and 5 retries with backoff. Also bump openai template pod memory to 6Gi
(kernel OOM traced anon-rss ~4.1GB busting the 4Gi limit).

Co-Authored-By: Claude <noreply@anthropic.com>

* swe_smith: self-documenting timestamped run logs in launcher

Redirect each role's stdout+stderr to a timestamped file
(/tmp/agl_logs/{role}_{model}_{YYYYmmdd-HHMMSS}.log) with a stable
{role}_latest.log symlink, so repeated server/trainer restarts no longer
overwrite each other's logs. A fixed name like /tmp/train_9b_async.log
silently loses the previous run's crash trace on relaunch.

Opt out with AGL_LOG_TO_FILE=0; override dir/name via AGL_LOG_DIR/AGL_LOG_FILE;
auto-skipped for --ci smoke tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: dedupe triplet model requests by prompt tokens

* feat: add retry logic to non-GET HTTP requests to harden the trainer

Make every non-GET request in the trainer process resilient to transient
failures so a single HTTP error can no longer crash training mid-step:

- register_model (POST /api/models): retry with backoff (idempotent upsert)
- _resume_gateway (POST /proxy/resume): retry with backoff (idempotent)
- _create_rollouts (POST /api/rollouts): pre-assign client rollout_ids and
  retry; server-side enqueue is now idempotent (existing id returns the
  existing rollout, events untouched), so retries never duplicate rollouts
- delete_model / _delete_rollout: best-effort, swallow errors
- add DELETE /rollouts/{id} (idempotent, cascades to events) and delete
  completed rollouts from the managers to keep server-side state bounded

Also switch AglLiteSyncClient's retrying GET to print over logging for
consistency with the rest of the codebase.

Co-Authored-By: Claude <noreply@anthropic.com>

* swe_smith: prevent git-history reward hacking in smith_agent

SWE-smith testbed repos ship the full git history, so the agent can recover
the injected-bug fix (and the deleted FAIL_TO_PASS tests) via
`git checkout <pre-bug-sha> -- <src>` / `git show` / `git log -p`. On the
Qwen3.5-9B run this inflated val/reward to ~0.80 (96% of resolved val rollouts
used the git hack; ~0% solved cleanly).

Agent-side mitigation:
- relocate_git(): move /testbed/.git out of the worktree after checkout (O(1)
  same-fs rename); the harness still reaches it via _git_base()
  (--git-dir/--work-tree) for restore_f2p_tests + capture_patch.
- _forbidden_action(): reject any agent command that invokes git or reads git
  metadata (.git / --git-dir / the relocated dir), returned as an observation;
  wired into run_agent_loop before _run.
- _agent_env(): strip SMITH_HIDDEN_GIT_DIR and any var leaking the path so the
  relocated dir is invisible in the pod env.
- Drop the git-based submission step from the prompt (harness captures the
  patch independently) and note that git is disabled.

Verified on 50 random val instances (base Qwen3.5-9B, isolated pods):
relocation 50/50 (O(1)), 0 leaks, 0 false-positive blocks; submission and
eval/patch-capture work via --git-dir; blocked git attempts recover to manual
edits without hindering legitimate solves.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: log rollout trajectories as wandb artifacts

* feat: add rollout DELETE endpoint and auto-cleanup in rollout managers

Add DELETE /rollouts/{rollout_id} (idempotent, cascades to events) and a
retrying GET on AglLiteSyncClient. Sync manager deletes each round's
completed rollouts; async manager deletes non-carry-over groups on
completion, keeping server-side state bounded during training.

Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 149400351dc1fe16c2bd98a0a8eae34d3931da85)

* feat: add retry logic to non-GET HTTP requests to harden the trainer

Make every non-GET request in the trainer process resilient to transient
failures so a single HTTP error can no longer crash training mid-step:

- register_model (POST /api/models): retry with backoff (idempotent upsert)
- _resume_gateway (POST /proxy/resume): retry with backoff (idempotent)
- _create_rollouts (POST /api/rollouts): pre-assign client rollout_ids and
  retry; server-side enqueue is now idempotent (existing id returns the
  existing rollout, events untouched), so retries never duplicate rollouts
- delete_model / _delete_rollout: best-effort, swallow errors
- add DELETE /rollouts/{id} (idempotent, cascades to events) and delete
  completed rollouts from the managers to keep server-side state bounded

Also switch AglLiteSyncClient's retrying GET to print over logging for
consistency with the rest of the codebase.

Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 137506f293f5287ee62dcacdab358c43f9c40825)

* fix: align rollout cleanup retry merge

* swe_smith: update reward_table.db — 24836 instances (85% of verified)

Qwen3.5-9B pure-rollout over train_dataset_verified.jsonl (is_train=false, n=4,
throttled 50 in-flight, deduped by data_id). 99647 rollouts / 24836 instances:
all-0 13789 (55.5%), all-1 6575 (26.5%), mixed 4472 (18.0%).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* swe_smith: lower pod memory request to 2Gi, keep 6Gi limit

request=limit=6Gi over-reserved: the scheduler pinned ~83 pods against the
node's 510Gi allocatable while pods actually use ~1-2GB, leaving CPU at 16%
and 150Gi RAM idle. Drop request to 2Gi so more pods schedule; keep limit at
6Gi so long-trajectory pods (kernel-observed anon-rss ~4.1GB) don't OOM.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: route rollouts deterministically by rollout_id hash for prefix caching

Replace round-robin server selection with a stable sha256(rollout_id) mod
pool-size mapping so every request from a rollout lands on the same endpoint,
maximizing prefix-cache hits. Sort the pool by endpoint to keep the mapping
independent of registration order.

Co-Authored-By: Claude <noreply@anthropic.com>

* Log compact rollout trajectory artifacts

* swe_smith: lower pod memory request to 1Gi, keep 6Gi limit

Co-Authored-By: Claude <noreply@anthropic.com>

* swe_smith: Qwen3.5-9B run defaults + tighten token caps, drop internal plan.md

- run_qwen3_8b_async.sh: default model Qwen3-8B->Qwen3.5-9B, max_model_len
  32768->65536, expose AGL_ROLLOUT_N, tie ppo_mini_batch_size to train_batch_size,
  async overrides (val_before_train=False, test_freq=-1), run-name qwen35_9b_async.
- job-template-openai.yaml: AGL_MAX_TOKENS 16384->12288, add SMITH_OBS_CHAR_CAP=6000
  to keep agent trajectories shorter under the 65536 context window.
- remove examples/swe_smith/plan.md (internal planning notes, not shipped code).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update SWE-smith training settings

* swe_smith: final Qwen3.5-9B reward table + 5245 mixed instances

Collection complete over train_dataset_verified.jsonl (is_train=false, n=4,
throttled 50 in-flight, deduped by data_id via reward db). Final:
29976 instances / 120283 rollouts — all-0 17109 (57.1%), all-1 7622 (25.4%),
mixed 5245 (17.5%). 82.5% are degenerate (zero GRPO advantage); the 5245 mixed
instances are the trainable set.

- reward_table.db: final SQLite snapshot (30MB).
- mixed_instance_ids.txt: the 5245 mixed data_ids (instances with GRPO signal).
- enqueue_throttled.py: throttled enqueuer with --skip-from-db (dedup that
  survives a server restart — needed after the in-memory store was wiped).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* swe_smith: prune unused scripts, add log/reward analysis tooling

- Remove no-longer-used enqueue/gen/monitor/serve scripts and stale docs.
- job-template-openai: point rollout-logs hostPath at /agl-logs (minikube 9p
  mountpoint) so pod logs reach the host under the docker driver.
- Add filter.md (full->verified dataset filtering rationale), scan_abnormal_pods.sh
  (k8s abnormal-pod probe), stat_pod_logs.sh (bulk pod-log anomaly stats), and
  report_qwen35_9b.md (Qwen3.5-9B rollout report: 34% resolve, 83% ctx overflow).

Co-Authored-By: Claude <noreply@anthropic.com>

* swe_smith: remove unused pod monitoring/log scripts

Co-Authored-By: Claude <noreply@anthropic.com>

* Add SWE Verify checkpoint evaluation

* Add B200 Qwen3.5-9B vLLM pure-decode roofline benchmark

Measure single-card B200 decode performance vs theoretical roofline for
Qwen3.5-9B (hybrid GatedDeltaNet + full-attention). Includes:
- theoretical roofline (mem/compute roofs, ridge, TPOT floor)
- batch sweep + context sweep via latency-subtraction (pure decode isolation)
- reusable bench + plot scripts, CSV data, roofline.png, README + summary

Key: ~24k tok/s/card decode ceiling (21% of compute roof); batch-1 at 51%
of bandwidth roof (hybrid recurrent-state overhead); empirical ridge ~batch
32-64; context 128->64k drops 4x but <=4k near-lossless (hybrid KV advantage).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* roofline: document measured HBM BW / BF16 TFLOPS in README

Roof charts & theory keep datasheet spec (8.0 TB/s, 2250 TFLOPS) per roofline
convention. Add README section 1.4 with empirically-measured achievable ceilings
(HBM 7.11 TB/s = 89% of spec; BF16 dense 1623 TFLOPS = 72% of spec) plus two
reusable microbenchmark scripts, so real attainment can be cross-referenced
(batch-1 = 57% of measured BW roof; batch-256 = 29% of measured compute roof).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add per-step + per-minute rollout stats collector with long-tail metrics

Instrumentation for the async-GRPO SWE-smith run (Qwen3.5-9B, 4xB200) to
quantify per-vLLM-call long tails and their effect on rollout throughput.

- collect_rollout_stats.py: 60s collector writing two tables from vLLM
  Prometheus histograms (e2e latency, response/prompt tokens).
- rollout_step_stats: per-call avg prompt/resp len, per-card out tokens +
  throughput, max response, and per-call long-tail percentiles
  (resp_tok/e2e_lat p50/p90/p99 + tail_ratio_lat_p99_p50).
- gen_window_throughput: per-~60s throughput time-series inside each gen
  window, showing tail-segment collapse.
- rollout_stats.db: 18 step rows + 177 per-minute rows.
- README documents schema + findings: per-call e2e p99/p50 ~12-18x every
  step (driven by ~21x response-token tail); within-window throughput
  collapses 88-99.7% from peak; effective throughput ~62% of peak.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* swe_smith: reward==1 long-turn penalty + val temperature 0.7; prune scratch files

- smith_agent.py: add length_penalized_reward() that penalizes ONLY solved
  (reward==1) trajectories: reward = 1 - λ·clip((n_turns-T0)/(max_turns-T0),0,1);
  run_agent_loop now returns (submitted, turns_used). Tunable via env
  SMITH_LEN_PEN_T0 (default 55) / SMITH_LEN_PEN_LAMBDA (default 0.2). Reports
  raw_value + n_turns on the reward event for monitoring.
- job-template-openai.yaml: surface SMITH_LEN_PEN_T0=55 / SMITH_LEN_PEN_LAMBDA=0.2.
- tests: cover the penalty (solved-only, within-budget, ramp-to-cap, degenerate
  span) and unpack the new run_agent_loop tuple.
- server.yaml: default_proxy.val.temperature 0 -> 0.7 (validation samples at 0.7).
- README: add `export FLA_TILELANG=0` to the run recipe.
- Remove scratch/dev files (enqueue_rollouts.py, rollout_stats.py, gen_*.py,
  monitor_controller.sh, run_qwen3_8b_async.sh, serve_vllm_qwen3_8b.sh,
  watch_failpods.sh, reward_table.db, reward_table_8b_baseline.md, rollout_test.md,
  delete.jsonl).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* swe_smith: update mixed_instance_ids.txt to 5407 (add 162 rescued from 128k rerun)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* swe_smith: block network-based reward hacking (NetworkPolicy + agent guards)

Agents were downloading upstream correct source (curl raw.githubusercontent,
pip install <target pkg>, wget, urllib) to overwrite the buggy file — ~26% of
rollouts — after git was already blocked. Two layers now cut this off:

- Network root fix: deploy Calico VXLAN (replacing flannel, which cannot enforce
  NetworkPolicy) + default-deny egress that only allows the agl-lite server IP,
  so pods have no public internet. See canal.md for the full deploy/verify/
  rollback runbook. Adds canal-policy-only.yaml, networkpolicy-egress-lockdown.yaml,
  and an app=agl-rollout label on the job template pod.
- Code backstop: extend smith_agent.py _forbidden_action to reject curl/wget/
  pip-install/urllib and test-harness file writes (conftest/pytest.ini/
  sitecustomize), and align the prompt (drop "you may install it").

Also enables fused kernels in the train configs (unrelated perf tweak).

Co-Authored-By: Claude <noreply@anthropic.com>

* swe_smith: gate long-turn penalty to training only; README -> mixed dataset

- smith_agent.py: length_penalized_reward() now takes is_train and only shapes
  reward when is_train AND solved. Validation reward stays the true, unshaped
  metric (it drives checkpoint selection). main() derives is_train from
  AGL_OPENAI_BASE_URL (/mode/train/ marker; fail-safe: never penalize val) and
  logs mode. Fixes the earlier bug where the penalty applied to val too.
- tests: pass is_train explicitly; add test_length_penalty_skips_validation
  asserting val reward is never reshaped (even a long solved rollout keeps 1.0).
- README: point the active Qwen3.5-9B recipe at train_dataset_mixed.jsonl (6343
  instances = mixed_dataset minus the 3 network-dependent repos dspy/pydantic/
  MONAI whose eval needs runtime pip install and breaks once pods are offline).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* swe_smith: add prompt-length penalty (plan B) + align train config to bh103or9

Penalize context bloat on SOLVED training rollouts: track the largest
server-reported usage.prompt_tokens per rollout (no local tokenizer) and
subtract up to 0.1 as the longest prompt grows from 50K to 64K tokens.
Gated on raw solved status so it stacks with the long-turn penalty (plan A)
and never reshapes validation reward. Tunable via SMITH_PROMPT_PEN_*.

Align train config to the bh103or9 run: model Qwen3.5-9B, gpu_mem_util 0.8,
max_num_batched_tokens 8192, ppo_max_token_len_per_gpu 16384.

Co-Authored-By: Claude <noreply@anthropic.com>

* add max_ppo_update_times

* Merge rollout-level advantage computation (cherry-pick ee78dbe)

Add optional rollout-level advantage (algorithm.enable_rollout_level_advantage)
and its module + tests, wired into AglLiteRayPPOTrainer alongside the existing
compute_advantage path. Cherry-picked from SiweiPro's ee78dbe to take only the
advantage change without the unrelated swe_verify work on that branch.

Co-Authored-By: SiweiPro <18474108006@163.com>
Co-Authored-By: Claude <noreply@anthropic.com>

* swe_smith: turn penalty T0=80, enable max_ppo_update_times=2 + bypass_mode

- Long-turn penalty now starts at 80 turns (was 55), max penalty 0.2 at the
  100-turn cap.
- Set agentlightning.max_ppo_update_times=2 to bound PPO updates per step.
- Enable rollout_correction.bypass_mode: reuse rollout log-probs as old_log_prob
  for importance sampling, skipping the old-logprob forward pass.

Co-Authored-By: Claude <noreply@anthropic.com>

* swe_smith: untrack examples/swe_smith/infra (local-only tooling)

Roofline benchmarks + rollout-stats tooling are local infra, not part of the
training example. Remove from the tree and exclude locally so they stay off origin.

Co-Authored-By: Claude <noreply@anthropic.com>

* swe_smith: untrack reward_table.db (large local-only artifact)

30MB reward cache is a local build artifact, not source. Remove from the tree
and exclude locally so it stays off origin.

Co-Authored-By: Claude <noreply@anthropic.com>

* swe_smith / swe_verify: untrack local-only files (docs, dataset curation, network policies, comparison scripts, swe_verify example)

Move these to .git/info/exclude so they stay off origin while remaining local:
- examples/swe_verify/  (7 files, plus tests/examples/test_swe_verify.py to avoid CI ImportError)
- examples/swe_smith/README.md
- examples/swe_smith/canal.md, filter.md, report_qwen35_9b.md  (internal notes)
- examples/swe_smith/canal-policy-only.yaml, networkpolicy-egress-lockdown.yaml
  (cluster-specific network policies)
- examples/swe_smith/mixed_instance_ids.txt  (local dataset curation, 5407 rows)
- examples/calc_x/comparison/  (local benchmark scripts)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: hzy46 <362583303@qq.com>
Co-authored-by: SiweiPro <18474108006@163.com>
2026-07-16 16:28:53 +08:00
Zhiyuan He f0a77cfad7 docs: update AgentOS package name (#532)
Deploy Documentation / deploy (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
2026-07-16 03:25:46 +00:00
Zhiyuan He d6df2e584d Search r1 completion api (#44)
* Add Search-R1 completion API agent

* End Search-R1 rollouts on invalid action
2026-07-16 11:03:01 +08:00
Zhiyuan He 8fd5bd8ea1 add completion api (#43) 2026-07-15 16:54:01 +08:00
Zhiyuan He ee8d8ff828 fix shuffling issue & zero sample issue (#41) 2026-06-29 15:28:40 +08:00
Ldemon aa6ab2c654 Fix empty triplets from model errors (#39) 2026-06-16 13:59:09 +08:00
Zhiyuan He 9e6ed8f8b5 Clean hooks (#38) 2026-06-10 15:30:11 +08:00
Zhiyuan He fdf8ec957d Clean legacy example & tests (#37) 2026-06-10 15:12:32 +08:00
Zhiyuan He f195883f95 Support verl==0.8.0 (#36) 2026-06-10 15:10:09 +08:00
Zhiyuan He ec5e62aadb Clean installation & documentation (#35)
* chore: simplify installation dependencies

* chore: simplify verl installation scripts

* docs: add installation guide

* docs: remove verl sync warning

* fix: use local calc-x data paths

* chore: streamline verl setup

* docs: clarify verl cuda variants
2026-06-09 20:55:33 +08:00
Zhiyuan He 7bfb0e7b15 Refactor verl part (#31) 2026-06-09 11:37:08 +08:00
Siwei Zhang 9fcb436b1a Update Search-R1 Llama 3 defaults (#32)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 11:48:04 +08:00
Siwei Zhang ee707b6626 feat: add search_r1 example (#30)
* feat: add search_r1 example

* Handle null rollout metadata

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 11:49:48 +08:00
Ldemon 0b44d5d5d3 feat(is): bypass-mode rollout importance-sampling + science_world async training (#27)
* feat(is): add bypass-mode rollout importance-sampling support

Capture chosen-token rollout logprobs end to end and feed them into VERL
bypass mode so old_log_probs = rollout_log_probs, avoiding actor recompute.

- proxy: inject logprobs=True on train-mode requests
- events: extract finite chosen-token logprobs (chat + completions schemas);
  never fail the triplet query on bad logprobs, report status/error instead
- rollout_bridge: thread response_log_probs through triplets/trace/trajectory,
  pad/truncate in lockstep with response_mask, fill masked/dropped positions
  with finite 0.0, drop rows with missing/invalid/length-mismatched logprobs
  (with metrics), emit float32 rollout_log_probs only when present
- trainer: import apply_bypass_mode; in _async_train_step, when bypass_mode is
  enabled, require finite rollout_log_probs and skip _compute_old_log_prob

* feat(science_world): enable bypass-mode rollout correction (IS)

Add algorithm.rollout_correction with bypass_mode + ppo_clip so the
science_world example trains using rollout logprobs (RS/IS off for first pass).
2026-06-03 15:25:57 +08:00
Zhiyuan He 77001e158d Merge pull request #29 from agent-lightning/zhiyuhe/fix_typo
fix typo and clean deploy folder
2026-06-03 11:54:09 +08:00
hzy46 e1d42667c8 fix typo and clean deploy folder 2026-06-03 11:52:44 +08:00
Zhiyuan He 398dabfc91 Merge pull request #28 from agent-lightning/agl-lite-llm-sandbox
fix: llm-in-sandbox-minikube
2026-06-03 11:34:36 +08:00
SiweiPro 1e63f4b89c fix: llm-in-sandbox-minikube
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:13:21 -07:00
hzy46 2955648c69 fix: polish agl lite 2026-06-02 03:35:37 +00:00
SiweiPro 8c0f2dcc77 feat: add llm-in-sandbox verl example 2026-06-02 03:35:34 +00:00
hzy46 d95c303a8b feat: refactor agl lite 2026-06-02 03:34:48 +00:00
ldemon eca8429228 fix(async_rollout): reset vLLM engine state on val→train boundary
When test_freq fires inside async_fit(), validation runs through the legacy
_rollout(is_train=False) path which ends with abort_all_requests(). Without
a subsequent update_weights() call, vLLM 0.7.1+ engines can stay paused and
the next step's resume_generation() does not recover them. Observed as the
step-32→33 deadlock: all rollout replicas hang at 20:42:17Z, training enters
a 16-hour zombie state with succeeded=82, timeout_rollouts growing by 256
per step, reward=0, grad_norm=0, and global_seqlen=32 (placeholder-only).

train→train avoids this because _async_train_step always ends with
sleep_replicas() + update_weights(global_steps), which forces a vLLM
engine state refresh while vLLM is offloaded. val→train must do the same
explicitly. Snapshot/restore of the async carry-over bridge state
(_validate_preserving_async_carry_over) was already in place; the missing
piece was the engine reset itself.

The reset must call sleep_replicas() BEFORE update_weights(): the reset
runs while vLLM is still awake holding weights + KV cache on GPU, and
FSDP's state_dict() unshard collides with vLLM's resident memory during
DTensor redistribute() — observed OOM at step 32→33 on 40 GB A100s with
GPU 0 at 39.49 GB used / 29.88 MiB free, vLLM TP worker holding 26.94 GiB,
FSDP unshard needing ~12.48 GiB. Mirror the train→train cycle pattern
(sleep_replicas() then update_weights()) so vLLM frees its weights + KV
cache before FSDP gathers params for IPC.

Tests:
- positive: drive async_fit through one train step plus a test_freq-
  triggered validation; assert sleep_replicas runs between val_rollout
  and the engine-reset update_weights(global_steps) call.
- negative: assert no spurious engine reset (neither sleep_replicas nor
  extra update_weights) fires on non-val steps — a stray reset would
  leave vLLM paused without a wake_up partner.
Verified the positive test fails on the pre-fix code with the intended
assertion message.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 08:40:24 +00:00
ldemon b260ed4d5a feat: add local mode for async calc-x example
- add CalcXAgent adapter for local_worker

- add run.sh --local to start serve plus local controller

- document local mode and declare calculator MCP dependency

Tests: bash -n examples/async_calc_x/run.sh

Tests: uv run pytest tests/examples/test_async_calc_x_agent.py tests/controller/test_local_worker.py -q

Manual: AGL_HOST_PORT=18080 examples/async_calc_x/run.sh --local --ci-fast
2026-05-27 05:02:01 +00:00
ldemon e444cab8c7 feat(science_world): tune for 8x A100, split train/val temperature, inject AGL_IS_TRAIN
- tune .env.example, train_sw_agent.py, run.sh for 8x A100
- split train/val temperature in sw_agent.py
- expand README with the new sizing/temperature guidance
- LocalReconciler: inject AGL_IS_TRAIN=1|0 into rollout subprocess env so
  the agent can branch behavior (e.g. sampling temperature) on train vs val
2026-05-27 05:02:01 +00:00
ldemon 04eabd9fc6 fix: harden async rollout carry-over flow, unblock SW pipeline, export AGL_HOOKS
- reject carry-over saturation instead of allowing n_new=0 paths
- apply enqueue hooks through the shared async enqueue builder
- LocalReconciler: break out of reconcile loop on stop, _shutdown reaps
  finished procs before SIGKILL, _startup_cleanup carries job_name,
  cache narrowed pool_size/agent_class fields, _admit_queuing short-circuits
  on stop, register SIGTERM/SIGINT signal handlers in cli.py
- verl trainer: unblock ScienceWorld training pipeline (server/app.py and
  rollout_bridge.py wiring fixes)
- examples/science_world/run.sh: export AGL_HOOKS so trainer rollout bridge
  can locate the hooks module
2026-05-27 05:02:01 +00:00
Zhiyuan He 7789a86280 Merge pull request #20 from agent-lightning/feat/record_entropy_v2
feat: record VERL actor entropy
2026-05-25 15:55:39 +08:00
SiweiPro bc809ec535 feat: record verl actor entropy 2026-05-25 00:36:08 -07:00
Zhiyuan He 675767e197 Merge pull request #16 from agent-lightning/feat/process_pool
feat: local process pool runner + ScienceWorld example
2026-05-22 12:45:15 +08:00
Zhiyuan He 9303317969 Merge pull request #13 from agent-lightning/fix/vllm-pause-and-resume
fix: vllm pause and resume
2026-05-22 11:58:58 +08:00
Zhiyuan He 5ac5e8ae02 Merge branch 'main' into fix/vllm-pause-and-resume 2026-05-22 11:58:43 +08:00
Zhiyuan He bfbcf3c903 Merge pull request #4 from agent-lightning/feat/add-reward-metrics-in-logs
feat: add reward metrics in logs
2026-05-22 11:46:38 +08:00
Zhiyuan He 4a27e1cbe5 Merge branch 'main' into feat/add-reward-metrics-in-logs 2026-05-22 11:46:26 +08:00
Zhiyuan He 4e3e924e52 Merge pull request #5 from agent-lightning/feat/wandb_support
feat/wandb_support
2026-05-22 11:44:40 +08:00
Zhiyuan He 28376d57d7 Merge branch 'main' into feat/wandb_support 2026-05-22 11:44:23 +08:00
Zhiyuan He a6d1994646 Merge pull request #14 from agent-lightning/fix/verl_steup
fix: update verl setup dependencies
2026-05-22 11:35:41 +08:00
ldemon 97f846bf74 fix: harden client/server against httpx keep-alive race
Fixes a crash that brought down a long-running ScienceWorld GRPO RL
training job at step 92/124 (~5h 56min in) on 8xA100. Root cause was a
plain HTTP/1.1 keep-alive race between the trainer's httpx pool and the
agl-lite uvicorn server, not OOM or any GPU/CPU memory leak (verified:
GPU max_memory_allocated was a flat 20.8026 GB across all 92 steps).

Trigger
-------
The Ray-driven `_AglTaskRunner` actor uses a long-lived
`AglLiteClient` (httpx.AsyncClient) to poll `/api/rollouts/{rid}` and
post events to the server. Between bursts of traffic an idle pooled
socket can sit unused for >5s. uvicorn's default `timeout_keep_alive`
is 5s, so the server half-closes the connection. The very next request
that reuses the (now stale) socket fails at the transport layer with
`httpx.ReadError` ("Server disconnected without sending a response") or
`httpx.RemoteProtocolError` -- before the client has any HTTP-level
signal to act on.

Impact
------
Before this fix, a single transient transport error propagated
straight up the call stack:
  httpx.ReadError
  -> raises out of `_AglTaskRunner.run.remote(...)`
  -> `ray.get(...)` reraises as `RayTaskError(ReadError)`
  -> Python driver exits, atexit triggers `ray.shutdown()`
     (exit_type=INTENDED_USER_EXIT)
  -> GCS marks the job finished and force-kills *every* actor:
     8x vLLM HttpServer, 8x FSDP workers, the _AglTaskRunner actor
  -> raylet receives SIGTERM ~150ms later
  -> entire training job dies
On the 5/21 run this happened at 17:25:17.344 UTC at step 92, after
a successful POST to vLLM 50ms earlier. No checkpoint between
step_64 and step_92 was saved.

Fix (layered defense)
---------------------
1. Server side: bump `timeout_keep_alive` from the uvicorn default of
   5s to 120s in `agl_lite/cli.py::serve`. This eliminates the race
   in the common case for poll-heavy clients.

2. Client side: add `_RetryingTransport`, an
   `httpx.AsyncBaseTransport` wrapper around the default async
   transport that retries on transient transport errors
   (`ConnectError`, `ReadError`, `WriteError`, `RemoteProtocolError`,
   `PoolTimeout`) with exponential backoff (0.1, 0.2, 0.4, 0.8s; max
   4 attempts). HTTP-level errors (4xx/5xx) are *not* retried -- they
   are returned as-is so application code keeps full control.
   Wiring is centralized in `AglLiteClient.__init__`, so all ~20
   existing call sites benefit automatically; tests that inject their
   own `httpx.AsyncClient` (e.g. via `httpx.ASGITransport`) are
   unaffected because the wrapper is only installed when `client is
   None`.

Retrying POSTs is safe in this design: by definition a transport
error happens before any response headers arrive, so the server has
not produced any application-visible side effect for this request.

Tests
-----
- `tests/test_client.py::TestRetryingTransport`: 4 new tests cover
  retry-then-succeed (ReadError + RemoteProtocolError), retry on
  ConnectError, raising after exhausting attempts, and *not* retrying
  application-level 500 responses.
- Full unit suite (375 tests) passes; ruff + pyright clean on touched
  files.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-22 03:27:14 +00:00
ldemon 6fcbcd5913 feat: add local process pool runner + ScienceWorld example 2026-05-22 03:27:14 +00:00
SiweiPro fce6e1fde9 fix: update verl setup dependencies
Update the pinned VERL GPU stack and force flash-attn to build against the final torch ABI so the setup script installs a consistent environment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 02:07:28 -07:00
SiweiPro 46d8df5ee8 fix: vllm pause and resume 2026-05-21 01:58:03 -07:00
Zhiyuan He a939ed97d9 Merge pull request #6 from agent-lightning/feat/async
feat: async rollout training pipeline
2026-05-19 14:55:18 +08:00
ldemon 1b8b0e05fc feat: async rollout training pipeline
Add async rollout path: sample_iterator with carry-over scheduling,
rollout_bridge async entry points, gateway pause/drain, client auth,
async_calc_x example, and accompanying tests.
2026-05-19 06:41:51 +00:00
SiweiPro 3abc413cb6 feat: add wandb support 2026-05-14 01:55:55 -07:00
SiweiPro d31618ceb2 feat: add reward metrics in logs 2026-05-13 23:32:23 -07:00
SiweiPro caa77f1dfe feat: add reward metrics in logs 2026-05-12 07:33:55 -07:00
SiweiPro ba8faa0fe8 fix: drop with shuffle 2026-05-12 07:30:32 -07:00
Zhiyuan He ae44285677 Merge pull request #1 from agent-lightning/feat/agl-lite-ppotrainer
feat(verl): add agl-lite PPO trainer integration
2026-05-11 14:41:46 +08:00
SiweiPro 29b2e61cf3 feat(verl): add agl-lite PPO trainer integration 2026-05-09 07:23:01 -07:00
Jeonghye Kim 0b40cb724a New example: EMPO2 (#524) 2026-04-29 14:31:44 +08:00
yuqyang d60e54fe5a fix: increase MCP read_timeout_seconds to 30s for container cold start
mcp-server-calculator takes ~12s to start inside minikube containers
(Python import overhead in constrained environment). The default 5s
read_timeout_seconds in StdioServerParams kills 117/176 agents.

Also add /root/.local/bin to PATH in Dockerfile so the pre-installed
binary is found directly.
2026-04-13 01:18:44 -07:00
yuqyang 9acaa7964a fix: use direct mcp-server-calculator binary instead of uvx
uvx re-downloads the package on every invocation even when
pre-installed via 'uv tool install'. The 5s MCP init timeout
kills 117/176 agents.

Fix:
- Dockerfile: add /root/.local/bin to PATH after uv tool install
- calc_agent.py: call mcp-server-calculator directly instead of
  'uvx mcp-server-calculator'

This eliminates the download step entirely — the binary starts
instantly from the pre-installed tool environment.
2026-04-13 01:04:49 -07:00
yuqyang bfd2f124fe refactor: use store archive API instead of custom _archive_batch
Replace the custom rollout JSONL writer with a call to
client.archive_rollouts() after each generate_sequences batch.

Benefits:
- Writes full rollout + events (not just summaries)
- Frees hot store memory (purges archived rollouts)
- Uses the existing store JSONL format (archive.jsonl)
- Path defaults to $AGL_LOG_DIR/archive.jsonl
2026-04-13 00:43:34 -07:00
yuqyang d75088d485 feat: rollout archive + pre-install MCP calculator in agent image
(a) agent_loop.py: after fetching triplets, append each rollout to
    $AGL_LOG_DIR/rollouts.jsonl with: batch number, rollout_id,
    reward, triplet count/lengths, and original input. Token IDs
    are summarized (not dumped) to keep the file readable.

(b) Dockerfile.agent: pre-install mcp-server-calculator via
    'uv tool install' at build time. Previously uvx downloaded it
    on every pod startup, hitting the 5s MCP init timeout —
    117/176 agents failed in the last run because of this.

The hermes_tool_parser JSON errors in vLLM are expected — the
untrained model sometimes generates malformed tool call JSON.
This improves with training.
2026-04-13 00:37:23 -07:00
yuqyang 2f27d835e6 chore: gitignore calc_x/logs/ 2026-04-12 23:02:01 -07:00
yuqyang 8ed1b4f46e fix: hooks.py sys.path after moving from vllm/ to calc_x root
When hooks.py was at vllm/hooks.py, parent.parent pointed to
calc_x/ (where eval_utils.py lives). After flattening, hooks.py
is directly in calc_x/, so parent is sufficient.
2026-04-12 22:46:28 -07:00
yuqyang 812896373d fix: kill stale minikube mount before starting a new one
If a previous run.sh was killed without the EXIT trap firing
(kill -9, machine crash), the minikube mount process would still
hold the destination path. Detect and kill it via pgrep before
starting a fresh mount.
2026-04-12 22:39:47 -07:00
yuqyang 298c8c611b feat: minikube mount for agent logs + training.log capture
Agent pods write to hostPath /tmp/agl-lite/logs/<attempt-id>/agent.log
inside the minikube VM. run.sh now starts 'minikube mount' to bridge
the VM path to the host filesystem at $LOG_DIR/agents/.

After a run, the log directory contains:
  logs/<timestamp>/
    server.log       # agl-lite server (JSON structlog)
    training.log     # VERL output (Ray workers, metrics, progress)
    agents/          # per-agent logs via minikube mount
      <attempt-id>/agent.log

The mount process is backgrounded and cleaned up via trap on EXIT.
2026-04-12 22:39:09 -07:00
yuqyang 5d2efe65ea refactor: flatten calc_x config files + improve logging
(a) Move examples/calc_x/vllm/{hooks,gateway-config,.env.example} up
to examples/calc_x/. The vllm/ subdirectory was unnecessary — calc_x
only has one mode (unlike math-poc which has mock + vllm).

(b) Improve logging coverage:
- run.sh: tee training output to $LOG_DIR/training.log alongside
  the existing server.log. Both logs share the same timestamped dir.
- agent_loop.py: log batch summary after each generate_sequences
  (sample count, triplet/empty split, reward distribution, elapsed).
- proxy.py: debug-level log for each proxied request (rollout_id,
  model, stream, path).
2026-04-12 22:30:53 -07:00
yuqyang 526b9c73b4 feat: E2E calc_x VERL training smoke test PASSED 🎉
Full PPO training step completed successfully:
  Ray init → FSDP load → internal vLLM → register with gateway →
  validation (24 rollouts, reward@1=0.25) →
  training (128 rollouts via K8s agent pods + MCP calculator) →
  PPO update (pg_loss=-0.005, grad_norm=0.99) →
  final validation (24 rollouts, reward@1=0.1)

Key fixes in this session:
- Fresh HTTP client per generate_sequences (stale TCP connections)
- Add uvx binary to agent Docker image (MCP tools need it)
- Handle empty triplets with EOS token (avoid VERL crash)
- Load tokenizer via AutoTokenizer (AgentLoopManager has no tokenizer)
- Call super().__init__ properly (parent has compatible signature)

Mark 5c.6, 5c.7, and Phase 5c as completed.
2026-04-10 00:35:47 -07:00
yuqyang 578053b88b fix: call super().__init__ properly, load tokenizer via AutoTokenizer
The parent AgentLoopManager.__init__ (verl 0.7.1) accepts
(config, worker_group, rollout_resource_pool, reward_loop_worker_handles)
— compatible with our signature. The tokenizer is NOT set by the
parent (only AgentLoopWorker has it). Load it ourselves via
AutoTokenizer.from_pretrained for DataProto construction.
2026-04-10 00:20:25 -07:00
yuqyang f7bf3f7848 fix: initialize AgentLoopManager fields directly, skip mismatched super().__init__
The parent AgentLoopManager.__init__ expects (config, servers,
load_balancer_handle) but create() passes (config, worker_group,
rollout_resource_pool). The parent sets self.tokenizer from
model_config in its __init__, but the positional arg mismatch
caused errors.

Fix: initialize config, rollout_config, model_config, tokenizer,
and dataset_cls directly instead of calling super().__init__().
The create() classmethod still handles server initialization via
_initialize_llm_servers.
2026-04-10 00:14:37 -07:00
yuqyang 92d8a5dfea fix: use parent tokenizer from AgentLoopManager instead of _set_tokenizer
The parent AgentLoopManager.__init__ sets self.tokenizer from
model_config. Our _set_tokenizer was never called, causing:
  AttributeError: 'NoneType' object has no attribute 'eos_token_id'

Remove _set_tokenizer, use self.tokenizer from parent directly.
2026-04-10 00:09:42 -07:00
yuqyang a42eab12d7 fix: add uvx to agent image + handle empty triplets in DataProto
Two issues that caused training step crash:

1. Dockerfile.agent: COPY /uvx from uv image alongside /uv.
   Without uvx, the MCP calculator agent fails immediately with
   'FileNotFoundError: uvx', making zero LLM calls.

2. agent_loop.py: when triplets are empty (agent failed before any
   LLM call), insert a single EOS token in the response so VERL
   doesn't treat the sequence as 'aborted'. If ALL sequences are
   aborted, compute_data_metrics crashes with:
     RuntimeError: max(): Expected reduction dim for numel() == 0
   The EOS token makes it a valid (but zero-reward) sequence.
2026-04-10 00:01:12 -07:00
yuqyang 01cd9d07e9 fix: use fresh HTTP client per generate_sequences call
The AglLiteAgentLoopManager kept a single httpx.AsyncClient across
validation and training calls. VERL has long pauses between calls
(FSDP weight sync, CUDA graph capture) that caused idle TCP
connections to go stale, resulting in:
  RuntimeError: unable to perform operation on <TCPTransport closed=True>

Fix: create a fresh AglLiteClient (via async context manager) for
each generate_sequences invocation. The client is closed cleanly
after all HTTP work is done.
2026-04-09 23:43:35 -07:00
yuqyang 1cdb2c5914 docs: update 5c.7 E2E progress — validation completed!
Full pipeline working through validation:
  Ray init → FSDP load → vLLM → generate_sequences →
  agl-lite enqueue → K8s agent pods → gateway → vLLM →
  triplets → DataProto → validation metrics reported

val-core/unknown/reward/mean@1:0.0 (agents ran but didn't solve
correctly — expected for untrained model, pipeline is functional).

Remaining: TCP transport error when starting training step after
validation. Likely stale HTTP connection to agl-lite server.
2026-04-09 23:35:49 -07:00
yuqyang 2a99432616 fix: AGL_KEY persistence in state file + image name normalization docs
(a) AGL_KEY management:
- deploy.py: write AGL_KEY to .local/agl-lite.env state file alongside
  AGL_BASE_URL and AGL_NAMESPACE. Every process can source this file.
- run.sh: auto-source .local/agl-lite.env if AGL_KEY not in env.
  After deploy, source it again for the freshly written values.

(b) Image name normalization:
- build_images.sh: document the convention explicitly — argument is the
  directory name (e.g., calc_x), underscores normalize to hyphens for
  Docker tags (calc_x → calc-x-agent:dev).
- run.sh already uses '--include-example calc_x' (directory name).
2026-04-09 23:24:34 -07:00
yuqyang 705475bc1b docs: add wandb integration as future todo item
Essential for tracking training metrics across runs. Needs:
WANDB_API_KEY propagation to Ray workers, logger config toggle,
--wandb CLI flag in train_calc_agent.py.
2026-04-09 23:18:47 -07:00
yuqyang e4754e08ee fix: E2E fixes — wandb, AGL_KEY propagation, Ray cleanup, image build
- train_calc_agent.py: default logger to console only (no wandb)
- entrypoint.py: pass AGL_KEY, AGL_BASE_URL, WANDB_MODE to Ray workers
  via runtime_env env_vars (OmegaConf resolves env vars at compose time
  in the driver, but Ray workers need them too)
- run.sh: add 'ray stop --force' cleanup before starting training;
  remove external vLLM check (VERL manages internal vLLM); use
  .venv/bin/python instead of uv run; fix image build name (calc_x)
- Note: requires python3.12-dev for triton JIT
2026-04-08 01:02:49 -07:00
yuqyang 07e075dd06 docs: update 5c.7 status — code done, blocked on VERL config tuning
All integration code is implemented:
  - AglLiteAgentLoopManager (agent_loop.py)
  - Simplified entrypoint using verl's TaskRunner
  - Deleted AgentLightningTrainer

E2E reaches FSDP model loading + vLLM server launch.
Blocked on vLLM engine core startup failure — likely GPU memory
contention in hybrid mode (FSDP + vLLM on 1 GPU). Need to tune
gpu_memory_utilization or use n_gpus_per_node > 1.
2026-04-07 10:05:32 -07:00
yuqyang 287cff5ded fix: agent_loop import path and dataset serialization for verl 0.7.1
- agent_loop.py: fix import — verl.utils.ray_utils.auto_await (not
  verl.utils.async_utils). Remove try/except guard that masked real
  import errors.
- dataset.py: add serialize_dataset=True and original_data_files=None
  to LoadedDataset so RLHFDataset.__getstate__ works during Ray
  serialization.
- entrypoint.py: pass LoadedDataset directly (not via ray.put/ray.get)

E2E progress: FSDP model loads, vLLMHttpServer starts. Blocked on
missing python3-dev headers for triton JIT compilation.
2026-04-07 10:00:38 -07:00
yuqyang 485d3eb2e1 fix: entrypoint dataset passing and LoadedDataset for verl 0.7.1
- entrypoint.py: pass LoadedDataset directly to TaskRunner (not via
  ray.put/ray.get which wraps in ObjectRef). Use train_dataset_ref
  directly instead of ray.get().
- dataset.py: rewrite LoadedDataset to skip RLHFDataset.__init__
  entirely (verl 0.7.1 requires tokenizer/config args we don't have).
  Set self.dataframe directly from the in-memory sequence.
- Remove AgentDataset (unused after trainer.py deletion).
2026-04-07 09:51:40 -07:00
yuqyang dea1a9529d refactor(verl): migrate to 0.7.1 AgentLoopManager API
Replace AgentLightningTrainer (custom RayPPOTrainer subclass) with
AglLiteAgentLoopManager — a custom AgentLoopManager that:
  - Inherits parent's vLLM server management (weight updates work)
  - Overrides _init_agent_loop_workers to skip Ray agent actors
  - generate_sequences() delegates to agl-lite HTTP API:
    register models → enqueue rollouts → poll → fetch triplets →
    build DataProto with rm_scores

Key changes:
- NEW: agl_lite/verl/agent_loop.py — AglLiteAgentLoopManager
- DELETE: agl_lite/verl/trainer.py — AgentLightningTrainer no longer needed
- REWRITE: agl_lite/verl/entrypoint.py — use verl's standard TaskRunner
  pattern, inject agent_loop_manager_class via config
- UPDATE: config.yaml — add model_endpoint, timeout_seconds fields

The integration point moves from 'override half the trainer' to
'provide a custom AgentLoopManager' — a designed extension point in
verl 0.7.1. Standard RayPPOTrainer handles the training loop.
2026-04-07 09:45:06 -07:00
yuqyang 39ecc2233a docs: add 5c.7 — VERL 0.7.1 AgentLoopManager migration plan
VERL 0.7.1 replaced the trainer-subclass pattern with a built-in
AgentLoopManager extension point. Our current AgentLightningTrainer
(which overrides _train_step, _validate, fit) is incompatible.

New plan: implement AglLiteAgentLoopManager that wraps AglLiteDaemon,
set it via rollout.agent.agent_loop_manager_class config, and use
the standard RayPPOTrainer — no trainer subclass needed.

5c.6 (smoke test) blocked on this migration.
2026-04-07 09:36:04 -07:00
yuqyang 50ba96ed73 docs: update 5c.6 status — blocked on verl 0.6.0→0.7.1 API migration 2026-04-07 09:13:32 -07:00
yuqyang 18cfc88a90 fix: version alignment for VERL stack (verl 0.7.1 + vllm 0.12.0)
- Pin verl==0.7.1 (0.6.0 only supports vllm<=0.9.1, incompatible)
- Pin vllm>=0.10.2,<=0.12.0 (verl 0.7.1 supports up to 0.12.0)
- Fix load_reward_manager: remove num_examine kwarg (dropped in 0.7.1)
- setup_verl.sh: note flash-attn must be installed after uv sync
  via 'uv pip install flash-attn --no-build-isolation'
- Use .venv/bin/python instead of 'uv run' for training scripts
  (uv run enforces lockfile, won't see pip-installed flash-attn)

Known remaining: RayPPOTrainer API changed in verl 0.7.1 —
trainer.py and entrypoint.py need further adaptation (reward_fn
kwarg, create_rl_sampler signature, etc). Filed as TODO.
2026-04-07 09:13:07 -07:00
yuqyang da8256572e fix: Ray isolation for shared machines + working_dir workaround
- entrypoint.py: pass _temp_dir from RAY_tmpdir env var to ray.init()
  for shared machines. Set working_dir=None in runtime_env to prevent
  Ray from packaging the local module into a new venv (which lacks
  heavy deps like torch/verl).
- run.sh: export RAY_GCS_SERVER_PORT=0 and RAY_tmpdir to avoid
  conflicts with other users' Ray clusters on port 6379.
- Add 'import os' to entrypoint.py.

Known issue: VERL FSDP workers still fail to find flash-attn because
it's installed outside the uv lockfile (two-phase install). Next step:
either add attn_implementation override or resolve the flash-attn
packaging issue.
2026-04-07 09:01:23 -07:00
yuqyang 811ca1d6cc fix: two-phase VERL install with CUDA auto-detect fallback
flash-attn is source-only on PyPI and requires torch at build time,
making it impossible to install via uv sync (circular build dep).
Move flash-attn out of pyproject.toml [verl] extras and install it
in a second phase via 'uv pip install --no-build-isolation' after
torch is available.

setup_verl.sh improvements:
- Auto-detect CUDA: tries exact version (e.g., cu131), falls back
  to major.0 (cu130) if exact index doesn't exist on PyTorch
- Use --index-strategy unsafe-best-match so vllm resolves from PyPI
  even when the PyTorch index is present
- Two-phase install: uv sync (torch+verl+vllm) then flash-attn
- Also sync --extra dev to keep test deps available
- Verified: CUDA 12.8 → cu128, torch=2.9.0+cu128, 4 GPUs detected
2026-04-07 08:23:05 -07:00
yuqyang 25fac86231 feat: pin VERL deps to known-good versions from reference setup
Pin versions matching .local/examples/setup.sh:
  verl==0.6.0, ray==2.49.2, transformers==4.57.1

Drop click==8.2.1 pin (conflicts with base env, only needed
transitively by verl). Drop tensordict pin (verl==0.6.0
requires >0.9.0, reference setup.sh's 0.6.2 is incompatible).

Add numpy>=1.26.0 as explicit base dependency (daemon.py imports it,
was previously only available transitively).
2026-04-07 08:04:36 -07:00
yuqyang daec1a2513 refactor: CUDA-agnostic VERL deps + auto-detect setup script
Remove hardcoded PyTorch cu130 index from pyproject.toml. VERL deps
now list packages without CUDA coupling — the CUDA variant is selected
at install time.

Align version constraints with Agent Lightning:
  - vllm>=0.10.2,!=0.11.1,!=0.11.2,!=0.12.0 (flash-attn compat)
  - transformers>=4.55.0,!=4.57.2 (bug in 4.57.2)
  - verl>=0.5.0, torch>=2.8.0, flash-attn>=2.8.3, tensordict>=0.9.1

Add scripts/setup_verl.sh:
  - Auto-detects CUDA version from nvcc (e.g., 13.1 → cu130)
  - Falls back to CPU if nvcc not found
  - Accepts manual override: setup_verl.sh cu126
  - Verifies index is reachable before installing
  - Runs verification after install (torch, vllm, verl, ray, transformers)
2026-04-07 07:51:31 -07:00
yuqyang 6876699912 feat: add [verl] optional dependency group for VERL training stack
Add optional 'verl' extras in pyproject.toml with GPU-compatible deps:
  torch (cu126 via PyTorch index), vllm, verl, ray, transformers,
  flash-attn, hydra-core, omegaconf, tensordict, datasets, codetiming,
  autogen-agentchat, autogen-ext[openai], mcp.

Configure uv to fetch torch/torchvision/torchaudio from PyTorch's
CUDA 12.6 wheel index (explicit, won't affect other packages).
No conda needed — pip GPU wheels bundle their own CUDA runtime.

Install: uv sync --extra verl
Resolves: torch=2.9.0+cu126, vllm=0.13.0, verl=0.7.1, flash-attn=2.8.3
2026-04-07 07:47:04 -07:00
yuqyang aeeb5b24f1 fix: cleanup previous deployment before re-deploying in example run scripts
Both math-poc/run.sh and calc_x/run.sh now check if the namespace
exists and run 'agl-lite deploy --cleanup' before deploying fresh.
This avoids stale Jobs/pods from prior runs causing confusing behavior.

Also add AGL_LOG_DIR setup to calc_x/run.sh (log directory with
timestamp, matching math-poc pattern). The agent container already
writes to $AGL_LOG_DIR/agent.log when set.
2026-04-07 07:31:59 -07:00
yuqyang 6107f66810 feat(calc_x): 5c.5 — run script, cleanup, README
- run.sh: E2E entrypoint — source .env.example, verify vLLM,
  build images (--include-example calc-x), agl-lite deploy,
  wait for healthz, exec train_calc_agent.py with passthrough args.
- Delete old files: calc_agent.py (replaced by agents/calc_agent.py),
  tests/ (Agent Lightning specific tests, not applicable).
- README.md: rewrite with architecture diagram, setup instructions,
  quick start, standalone usage, and file listing.
2026-04-07 04:21:12 -07:00
yuqyang 4bb3748fe5 feat(calc_x): 5c.4 — training script rewrite for agl-lite
Rewrite train_calc_agent.py to use agl-lite VERL integration:
- Load parquet dataset via HuggingFace datasets
- Build OmegaConf config: Hydra compose from agl_lite/verl/config.yaml
  (base) + verl_default_config() overrides (Calc-X specific)
- AGL_BASE_URL and AGL_KEY read from env via OmegaConf resolvers
- Call run_ppo(config, train_dataset, val_dataset) directly
- CLI: --train-file, --val-file, --model, --ci, --ci-fast
- Drop all agentlightning deps: agl.VERL, agl.Trainer, OtelTracer,
  LlmProxyTraceToTriplet, LightningStoreClient, MongoLightningStore,
  WeaveTracer, n_runners, external_store_address, mongo, weave, lora
2026-04-07 04:19:36 -07:00
yuqyang 138da36ee9 feat(calc_x): 5c.3 — hooks and deploy config
- vllm/hooks.py: CalcXHooks(RolloutHooks) —
  on_enqueue: inject AGL_TASK_INPUT (question + id JSON) and
  AGL_MODEL_NAME into pod env via pod spec copy.
  on_succeeded: extract answer from agent_output event, compare
  with ground truth using eval_utils.scalar_are_results_same
  (sympy-based numeric comparison), post reward event.
- vllm/gateway-config.yaml: inject return_token_ids for all models
- vllm/.env.example: deploy config for agl-in-host mode —
  namespace, gateway, hooks, pod spec template, model endpoint,
  vLLM params, dataset paths for VERL training.
2026-04-07 04:17:59 -07:00
yuqyang 75942cb066 feat(calc_x): 5c.2 — standalone agent container
- agents/calc_agent.py: standalone agent (no agl-lite imports).
  Reads AGL_TASK_INPUT (JSON with question/id), runs AutoGen
  AssistantAgent with MCP calculator tool (uvx mcp-server-calculator),
  extracts answer via ### ANSWER: <answer> ### regex, posts
  agent_output event to AGL_EVENT_URL. 5 min timeout per problem.
- Dockerfile.agent: python:3.12-slim + openai, autogen-agentchat,
  autogen-ext[openai], mcp, uv (for uvx MCP server).
- job-template.yaml: pod spec with agent container, CPU-only,
  imagePullPolicy: Never (minikube).
2026-04-07 04:16:35 -07:00
yuqyang fcd61966bc feat(calc_x): 5c.1 — dataset sample and eval_utils cleanup
- data/sample.jsonl: 10 rows from train.parquet for smoke testing
- eval_utils.py: remove agentlightning dependency (@reward decorator,
  async evaluate, evaluate_v0_1); keep pure functions
  (scalar_are_results_same, evaluate, float_eval, normalize_option)
- .gitignore: exclude data/ but keep sample.jsonl
- Add sympy as dev dependency (used by eval_utils for numeric comparison)
2026-04-07 04:14:54 -07:00
yuqyang fe33bfd250 docs: backlog item for multi-backend image builds (docker registry support) 2026-04-07 04:11:14 -07:00
yuqyang 8ab16e5c9e refactor: convention-driven image builds, eliminate case/switch
build_images.sh now iterates over examples using conventions:
  - examples/<name>/Dockerfile.agent → <name>-agent:dev
  - examples/<name>/build-extra.sh  → optional hook for extra images
  - 'all' auto-discovers examples with Dockerfile.agent
  - underscores normalized to hyphens in Docker tags

Move math-poc's mockai build into build-extra.sh hook.
2026-04-07 04:07:01 -07:00
yuqyang 626849b963 refactor: build_images.sh uses --include-example for extensibility
Replace --math-poc flag with --include-example <name> (repeatable).
Supports: math-poc, calc-x, and 'all'. Legacy --math-poc still works.

Update math-poc/run.sh to use new flag. Update todo to reflect
dataset decision (manual download, already in data/).
2026-04-07 04:02:03 -07:00
yuqyang 374b3a29c5 ignore data in example calc_x 2026-04-07 03:57:31 -07:00
yuqyang dd01656483 docs: detailed implementation plan for Phase 5c (calc_x VERL training)
Break down the calc_x migration into 6 ordered sub-items:
  5c.1: Dataset download script + eval_utils cleanup
  5c.2: Standalone agent container (AutoGen + MCP)
  5c.3: Hooks and deploy config (agl-in-host + vLLM)
  5c.4: Training script rewrite (run_ppo, drop agentlightning deps)
  5c.5: run.sh E2E entrypoint + old file cleanup
  5c.6: Smoke test with --ci-fast

Architecture: run.sh handles infra setup (vLLM check, Docker build,
agl-lite deploy), then exec's into train_calc_agent.py which loads
data and calls run_ppo(). For iterative dev, run train_calc_agent.py
directly with infra already up.
2026-04-07 03:17:51 -07:00
yuqyang 44e4501007 chore: remove completed todo items (gateway assemblers, settings refactor, logging) 2026-04-07 02:38:12 -07:00
yuqyang 2ebebfae9d docs: add todo item for Anthropic /v1/messages token_ids support
vLLM's Anthropic-compatible endpoint does not appear to support
return_token_ids yet. Need to verify before relying on it for
training pipelines.
2026-04-07 02:37:15 -07:00
yuqyang 1b60f07540 docs: document vLLM-specific token_ids fields in assembler docstrings
Clarify that prompt_token_ids and per-choice token_ids are vLLM
extensions (vllm#22587), not part of the standard OpenAI API.
Assemblers preserve them when present and omit them when absent —
no configuration needed.
2026-04-07 01:56:31 -07:00
yuqyang 5d40fa78cb refactor: extract streaming assemblers into gateway/assemblers/ sub-package
Move streaming response assembly out of proxy.py into a dedicated
sub-package with one module per LLM API format:

- assemblers/__init__.py: registry (select_assembler) mapping path
  suffixes to format-specific assembler functions
- assemblers/chat_completion.py: OpenAI /v1/chat/completions (moved)
- assemblers/completion.py: legacy /v1/completions (new)
- assemblers/anthropic.py: Anthropic /v1/messages (new)

proxy.py now calls select_assembler(path) instead of an inline is_chat
check. Unknown paths fall back to raw {"chunks": [...]}.

Adding a new format = one new file + one registry entry.

Tests: 19 new unit tests for assemblers (registry dispatch, each format,
edge cases). Existing proxy integration tests updated. 336 total pass.
2026-04-07 01:44:51 -07:00
yuqyang 8c0d20ac85 fix(deploy): K8s server pod missing env vars (mock mode broken)
Two issues in agl-in-k8s mode (mock):

1. deploy/agl-lite/k8s.yaml had wrong env var names:
   GATEWAY_CONFIG -> AGL_GATEWAY_CONFIG
   HOOKS -> AGL_HOOKS
   ARTIFACT_DIR removed (dead field)
   Added: AGL_POD_SPEC_TEMPLATE, AGL_LOG_DIR (from ConfigMap)

2. deploy.py didn't write AGL_POD_SPEC_TEMPLATE to the ConfigMap,
   so on_startup couldn't find the pod spec template -> copy_pod_spec()
   raised RuntimeError -> containers field empty -> K8s rejected every Job

Both mock and vllm E2E runs now pass:
  mock: 10/10 rollouts, 2 iterations
  vllm: 5/5 rollouts, 1 iteration
2026-04-07 00:40:52 -07:00
yuqyang f036ecdb2f test(gateway): realistic chunk[0] shape in token_ids test
chunk[0] can carry both role+content AND token_ids simultaneously.
Updated test_streaming_preserves_token_ids to mirror real vLLM output:
  chunk[0]: delta={role, content='Hi'}, token_ids=[100], prompt_token_ids=[...]
  chunk[1]: delta={content=' there'}, token_ids=[200, 201]
  chunk[2]: delta={content='!'}, token_ids=[300], finish_reason='stop'

Verifies token_ids from ALL chunks (including chunk[0]) are concatenated.
2026-04-06 23:40:26 -07:00
yuqyang 920194ec0e fix(gateway+events): preserve token IDs through streaming assembly
_assemble_chat_completion now carries vLLM-specific training fields:
  - prompt_token_ids: lifted from first SSE chunk to top-level dict
  - token_ids: concatenated across all chunks into each choice

_trim_model_request (triplet extraction) updated:
  - dict branch (assembled) is now the primary path for both
    streaming and non-streaming responses
  - list branch kept for backward compat (pre-assembly format)

Without this fix, the assembly commit (c25eb23) silently dropped
all token IDs from streaming responses, breaking VERL triplet
extraction — prompt_token_ids and response_token_ids would both
be empty lists.

Tests: +2 (test_streaming_preserves_token_ids,
            test_streaming_token_ids_triplet_roundtrip)
Total: 316 passing
2026-04-06 23:33:04 -07:00
yuqyang 0e48b372af docs(todo): gateway streaming assembly — multi-format support
Added [discuss] item for handling multiple LLM API streaming formats
in the gateway proxy. Currently only chat/completions is assembled;
completions and messages (Anthropic) fall back to raw chunks.

Proposal: assembler registry keyed by path suffix — one function per
format, same Callable signature, first-match dispatch. SSE parsing
stays generic; format-specific logic isolated in assembler functions.
2026-04-06 22:29:00 -07:00
yuqyang 36e7cfe22e fix(gateway): decouple SSE parsing from OpenAI format
_parse_sse_response was format-specific (assumed choices[0].delta.content).
The gateway is a general-purpose proxy and must not interpret payload structure.

Split into two functions with clear contracts:
  _parse_sse_chunks(raw)          -- generic, format-agnostic SSE parser
  _assemble_chat_completion(chunks) -- OpenAI chat completion only; explicit docstring
                                       stating it must not be called for other paths

_forward_streaming now takes 'path' and dispatches:
  path ends with 'chat/completions' -> _assemble_chat_completion -> ChatCompletion dict
  any other path                    -> {"chunks": [...]} raw list

This means:
  /v1/chat/completions streaming  -> response: {id, object, choices[message], usage}
  /v1/completions streaming       -> response: {chunks: [{choices[0].text, ...}]}
  any custom endpoint             -> response: {chunks: [...]}

Tests: added test_streaming_non_chat_path_stores_raw_chunks
2026-04-06 20:44:56 -07:00
yuqyang c25eb23cdc fix(gateway): assemble streaming response into ChatCompletion shape
_parse_sse_response previously returned a list of raw SSE delta chunks.
This was:
  - inconsistent with non-streaming (which returns a single ChatCompletion dict)
  - verbose: 294 chunks per call vs one assembled response
  - inconvenient to inspect: consumers had to manually concat delta.content

Now assembles chunks into a unified dict::

    {
      "id": "chatcmpl-...", "object": "chat.completion",
      "created": <int>, "model": "<model>",
      "choices": [{"message": {"role": "assistant", "content": "<full text>"},
                   "finish_reason": "stop"}],
      "usage": <last chunk usage or None>
    }

Streaming and non-streaming model_request events now have the same shape.
Empty stream (no data chunks) returns {} consistently.

Tests updated to assert on assembled shape.
2026-04-06 20:38:02 -07:00
yuqyang db7e7151d0 fix(deploy+rl_loop): three bugs found during real vllm run
deploy.py — server subprocess inherits caller's pipe (blocks tee forever):
  - IN_HOST Popen now uses stdout=DEVNULL, stderr=DEVNULL
  - Server logs go to resolved_log_dir/server.log via configure_logging()

deploy.py — hook env vars not passed to server subprocess:
  - AGL_POD_SPEC_TEMPLATE (and any future hook vars) from .env file were
    never in the server's environment; server on_startup couldn't find the
    pod spec template -> RuntimeError -> HTTP 500 on every enqueue
  - Fix: load env file with dotenv_values() and merge into server_env with
    precedence: env_file < os.environ < explicit overrides (AGL_KEY, AGL_LOG_DIR)

rl_loop.py — DATA_DIR undefined:
  - DATA_DIR = Path(__file__).resolve().parent / 'data' was missing
2026-04-06 20:21:07 -07:00
yuqyang cd5649b1c2 fix(tests): update test_math_hooks for new pod_spec API
Fixtures now seed hooks._pod_spec directly (bypassing on_startup/
AGL_POD_SPEC_TEMPLATE) with a minimal pod spec matching job-template.yaml.

Assertions updated:
- result.config.environment_variables["AGL_TASK_INPUT"] (old, removed field)
  -> _get_task_input() helper: finds AGL_TASK_INPUT in pod spec container env list
2026-04-04 01:03:18 -07:00
yuqyang b5a693ebc9 chore(math-poc): AGL_LOG_DIR owned by run.sh, not .env.example
run.sh exports AGL_LOG_DIR=$LOG_DIR (the timestamped run directory)
so all components write to the same per-run folder:
  logs/<timestamp>-<mode>/server.log   (structlog JSON, IN_HOST only)
  logs/<timestamp>-<mode>/rl_loop.log  (plain text, via rl_loop.py)
  logs/<timestamp>-<mode>/archive.jsonl (rollout archive)

Remove tee from rl_loop.py invocation — rl_loop.py now writes the file
itself; tee would double-write to the same path.

Remove AGL_LOG_DIR=logs/ from mock/.env.example and vllm/.env.example —
run.sh sets it dynamically; a fixed path would accumulate across runs.
2026-04-04 01:00:06 -07:00
yuqyang c1e6c95863 feat(math-poc): update logging to use AGL_LOG_DIR
rl_loop.py:
- log() tees to both stdout and AGL_LOG_DIR/rl_loop.log (append mode)
- _setup_log_file() called at start of main()
- log startup banner includes log dir path
- archive_rollouts: no backend when AGL_LOG_DIR set (server defaults to
  AGL_LOG_DIR/archive.jsonl); explicit local path only as fallback

agents/qa_agent.py:
- _setup_logging(): stdlib logging to stdout + AGL_LOG_DIR/agent.log
  mkdir -p handled by logging setup; called at module level
- all print() -> log.info() / log.warning() / log.error()
2026-04-04 00:53:38 -07:00
yuqyang 4c2de5614d feat(7b): per-pod log volume via PodPatcher
job-template.yaml.j2:
- AGL_LOG_DIR=/agl/logs/$(AGL_ATTEMPT_ID) added after AGL_ATTEMPT_ID
  (ordering invariant: K8s resolves $(VAR) in declaration order)
- volume_mounts: agl-logs -> /agl/logs (injected into all containers)
- volumes: agl-logs hostPath /tmp/agl-lite/logs, type: DirectoryOrCreate

job_builder.py:
- PodPatcher: add volume_mounts field
- _apply_patcher: inject volume_mounts into all containers (patcher first,
  container's own mounts win on name conflict)
- ordering invariant documented with load-bearing comment

tests: 3 new tests
- test_parsed_from_template: AGL_LOG_DIR present, AGL_ATTEMPT_ID before it,
  volume_mounts and volumes present
- test_volume_mounts_injected_into_all_containers
- test_container_volume_mount_wins_over_patcher
- test_env_ordering_invariant: patcher env before container env
2026-04-04 00:48:02 -07:00
yuqyang a58eb1189a feat(7a): structured logging — dual output, AGL_LOG_DIR, default archive
New module: agl_lite/logging_config.py
- configure_logging(log_dir, log_level, component)
- stdout: structlog ConsoleRenderer (human-friendly, colored)
- file:   structlog JSONRenderer (JSON Lines) at log_dir/<component>.log
- ProcessorFormatter bridge so stdlib logging (uvicorn etc.) also routes through
- shared processors: merge_contextvars, add_log_level, add_logger_name, TimeStamper

cli.py serve:
- add --log-dir (envvar AGL_LOG_DIR) and --log-level (envvar AGL_LOG_LEVEL)
- call configure_logging() before uvicorn.run()

ServerSettings: add log_dir field; passed to InMemoryStore

store/memory.py:
- stdlib logging -> structlog
- __init__: add log_dir param, store as self._log_dir
- archive_rollouts: default backend to log_dir/archive.jsonl when unset

deploy.py:
- DeploySettings: add log_dir field (AGL_LOG_DIR)
- resolve log_dir via (repo_root / cfg.log_dir).resolve() — handles relative + absolute
- fallback: local_state_dir when log_dir not set
- set AGL_LOG_DIR in subprocess env; remove stdout/stderr redirect
- error message references resolved_log_dir/server.log

env examples:
- deploy/agl-lite.env.example: add AGL_LOG_DIR + AGL_LOG_LEVEL comments
- math-poc mock + vllm .env.example: add AGL_LOG_DIR=logs/
2026-04-04 00:24:41 -07:00
yuqyang fd557ce49d docs(todo): revise 7a — AGL_LOG_DIR replaces AGL_LOG_FILE, dual output design
- AGL_LOG_DIR: unified dir for logs + archive across all processes
- stdout: ConsoleRenderer (human-friendly); file: JSONRenderer (JSONL)
- deploy.py resolves relative/absolute paths via pathlib
- default archive location: AGL_LOG_DIR/archive.jsonl
- AGL_LOG_LEVEL for verbosity control
2026-04-04 00:19:32 -07:00
yuqyang 634f438bca docs(todo): mark settings refactor completed; refresh 7a logging design 2026-04-03 23:43:38 -07:00
yuqyang 9fc5ec9c31 refactor: BaseSettings -> BaseModel; CLI owns env var mapping via typer envvar=
ServerSettings, ControllerSettings are now plain BaseModel — no ambient
env reads, no env_prefix, no pydantic-settings dependency.

cli.py is the sole boundary that maps AGL_* env vars to settings fields,
using typer.Option(default, envvar='AGL_*'):
- CLI arg wins if passed
- env var is fallback (read at call time by typer)
- --help shows env var name, not value
- type conversion (int) handled by typer
- required fields (base_url, namespace, job_manifest_template) use
  typer.Option(..., envvar=) — typer fails cleanly if neither arg nor
  env var is provided

DeploySettings stays BaseSettings (explicit _env_file, not ambient).

controller: all settings now explicit options in cli.py with envvar=;
previously only --job-manifest-template was explicit.

rename: ServerSettings.agl_key -> key (server/config.py, app.py, tests)
2026-04-03 23:33:27 -07:00
yuqyang 6143e04e4a docs: use typer.Option(envvar=) pattern for settings refactor
- typer reads env at call time (not import time)
- --help shows env var name, not value (no key leakage)
- type conversion handled by typer
- required fields: typer.Option(..., envvar=) fails cleanly
- no os.environ anywhere in cli.py
2026-04-03 10:01:54 -07:00
yuqyang 3a01068d1f docs: add 'CLI owns env mapping' principle to AGENTS.md and todo
Settings refactor: BaseSettings -> BaseModel, cli.py is the sole
boundary that reads os.environ. Captured as [ready] todo item.
2026-04-03 09:03:28 -07:00
yuqyang 2bfda1cf89 docs(todo): revise 7a logging design (server-only, structlog, --log-file CLI arg) 2026-04-03 08:53:12 -07:00
yuqyang 23ff6c8553 refactor: remove artifact event feature
No production consumers — only test_artifacts.py was exercising it.
SWE-bench uses a plain K8s hostPath volume named 'artifacts', unrelated.

Removed:
- InMemoryStore._persist_artifact, artifact_dir param
- Special 'artifact' branch in add_event
- ServerSettings.artifact_dir
- DeploySettings.artifact_dir
- --artifact-dir CLI option
- artifact_dir passthrough in deploy.py (IN_HOST cmd + ConfigMap env)
- tests/store/test_artifacts.py (7 tests)

todo: replace SWE-bench artifact follow-up with resources_id cleanup note
2026-04-03 08:29:54 -07:00
yuqyang 3ebd587eb9 docs(todo): add logging persistence plan (7a server/controller, 7b agent pod volume) 2026-04-03 08:17:27 -07:00
yuqyang d6518d29dd chore(math-poc): merge deploy.env + .env.example into single .env.example per mode
DeploySettings has extra='ignore', so deploy vars and experiment vars can
coexist in one file. run.sh now sources and passes the same .env.example
to both 'source' and '--env-file'. Removes deploy.env from both modes.
2026-04-03 07:26:56 -07:00
yuqyang c5447e39da chore(todo): collapse completed items, add config hygiene decisions to frozen table 2026-04-03 07:23:40 -07:00
yuqyang 4111c9261a chore: remove stale deploy.yaml files, fix .env.example header comments 2026-04-03 07:22:54 -07:00
yuqyang c4721693fa fix(math-poc): update for new API (pod_spec, .env deploy, no resources)
Hooks (mock + vllm):
- Replace RolloutConfig(image='') + .environment_variables[...] with
  copy_pod_spec() + get_container() + container env injection
- Add AGL_POD_SPEC_TEMPLATE docstring (loaded by base on_startup)

Deploy config:
- mock/deploy.yaml + vllm/deploy.yaml → mock/deploy.env + vllm/deploy.env
  (flat AGL_* key=value format matching DeploySettings)
- Add AGL_POD_SPEC_TEMPLATE=examples/math-poc/job-template.yaml

run.sh:
- --config → --env-file
- deploy.yaml → deploy.env; parse AGL_NAMESPACE with grep instead of python yaml
- AGL_K8S_NAMESPACE → AGL_NAMESPACE

rl_loop.py:
- Remove dead resources pattern: add_resources() + resources_id on enqueue
  (controller no longer fetches resources; pod spec set by hook)
- Remove yaml import (was only used for job template loading)
- Fix NameError: enqueued_ids → rollout_ids
- Fix NameError: import ArchiveBackend from agl_lite.schemas.api
- Remove resources_id from EnqueueRolloutRequest
- Drop resources_id from run_iteration() signature
- Update module docstring
2026-04-01 09:18:55 -07:00
yuqyang 668a44d513 fix: rename Jinja2 template variable lite_url -> base_url
Consistent with the ControllerSettings.base_url rename (AGL_LITE_URL ->
AGL_BASE_URL). Updates job_builder._template_context, job-template.yaml.j2,
and docs/concepts/task-lifecycle.md.
2026-04-01 09:12:57 -07:00
yuqyang 7cb50273ce fix: hardcode agl-lite-keys secret name, remove AGL_SECRET_NAME entirely
SECRET_NAME = 'agl-lite-keys' was already a hardcoded constant in deploy.py
with no way to configure it differently. The name was simply propagated as
AGL_SECRET_NAME through ConfigMap -> pod env -> ControllerSettings ->
job_builder -> Jinja2 template for no reason.

Changes:
- job-template.yaml.j2: replace {{ secret_name }} with agl-lite-keys
- job_builder.py: remove secret_name from _template_context
- ControllerSettings: remove secret_name field
- deploy.py: remove AGL_SECRET_NAME from agl-lite-config ConfigMap
- k8s.yaml: remove AGL_SECRET_NAME env var (was read from ConfigMap)
- tests: remove secret_name from all ControllerSettings fixtures; update
  assertion to expect hardcoded 'agl-lite-keys'
2026-04-01 09:09:31 -07:00
yuqyang add6bf50f4 fix: remove redundant CLI threading of base_url/namespace/secret_name in controller
These values are already in the pod env via agl-lite-config ConfigMap.
ControllerSettings (env_prefix=AGL_) reads them directly — no CLI option needed.

Changes:
- cli.py controller: only --job-manifest-template remains; all others read from env
- ControllerSettings constructor: only job_manifest_template passed explicitly
- AGL_K8S_NAMESPACE renamed to AGL_NAMESPACE everywhere (deploy.py, k8s.yaml)
  — fixes latent bug: ControllerSettings.namespace expected AGL_NAMESPACE but
    the ConfigMap key was AGL_K8S_NAMESPACE, so pydantic-settings silently
    failed to populate it from env
- deploy/controller/k8s.yaml: remove --agl-base-url, --namespace, --secret-name
  from command; env vars remain mounted from ConfigMap for pydantic-settings
2026-04-01 09:04:33 -07:00
yuqyang 103bf4e87a fix: remove all defaults from CLI options and settings classes
CLI (cli.py):
- serve: host, port now required (...)
- serve: gateway_config, hooks, artifact_dir → Optional[str] = None (genuinely optional)
- serve: remove manual os.environ fallback — settings already reads env vars
- controller: agl_base_url, namespace, secret_name now required (...)

ServerSettings (server/config.py):
- Remove host, port — they are uvicorn/CLI concerns, never read by create_app()
- gateway_config, hooks, artifact_dir → str | None = None

ControllerSettings (controller/config.py):
- base_url, namespace, secret_name: required (no default); fail loudly if missing

InMemoryStore:
- artifact_dir: str | None = None (consistent with ServerSettings)

Tests: pass all required fields explicitly
2026-04-01 08:57:18 -07:00
yuqyang 8a4f5e852f fix: rename ControllerSettings.lite_url → base_url (AGL_BASE_URL)
Previously the field was named lite_url, which pydantic-settings mapped to
AGL_LITE_URL. The pod injects AGL_BASE_URL, so the env var was silently
ignored — the CLI passed the value explicitly, masking the mismatch.

Renaming to base_url makes pydantic-settings correctly bind AGL_BASE_URL,
consistent with the rest of the codebase.
2026-04-01 08:51:54 -07:00
yuqyang 49e80825a0 fix: anchor job_manifest_template to repo_root in deploy.py
Relative default path only resolved correctly if CWD was repo root.
Using repo_root / cfg.job_manifest_template is consistent with all other
static paths and handles absolute user-supplied paths correctly (pathlib
discards the left operand when the right is absolute).
2026-04-01 08:46:08 -07:00
yuqyang ef298efb1a fix: move job_manifest_template default into DeploySettings
Default belongs in the settings class, not buried in the function body.
deploy_command() now just uses Path(cfg.job_manifest_template) directly.
2026-04-01 08:42:32 -07:00
yuqyang 79e357bde4 fix: remove default from --job-manifest-template cli option
The controller always runs in a pod; the CLI arg is always explicitly provided
by k8s.yaml. No default belongs in ControllerSettings or the CLI.
The deploy/controller/job-template.yaml.j2 default lives only in deploy.py
(deploy-time concern), which already has it.
2026-04-01 08:38:20 -07:00
yuqyang d3e15b4d6a fix: move AGL_JOB_MANIFEST_TEMPLATE default to cli.py, not ControllerSettings
ControllerSettings.job_manifest_template is required (no default) because the
controller always runs in a pod where the value is always supplied explicitly via
--job-manifest-template /etc/agl/job-template.yaml.j2 (see deploy/controller/k8s.yaml).
A host-side default in ControllerSettings was misleading and could mask
misconfiguration at pod startup.

The cli.py default (deploy/controller/job-template.yaml.j2) is for local dev only.
2026-04-01 08:36:49 -07:00
yuqyang ff5411eecd feat: base on_startup auto-loads AGL_POD_SPEC_TEMPLATE; AGL_JOB_MANIFEST_TEMPLATE default
ControllerSettings.job_manifest_template:
- Now has a default of "deploy/controller/job-template.yaml.j2"
- cli.py --job-manifest-template option no longer required (same default)

DeploySettings:
- Add pod_spec_template: str | None = None (AGL_POD_SPEC_TEMPLATE)
  User pod spec template — plain YAML pod spec fragment loaded by the base
  RolloutHooks.on_startup into self._pod_spec at server startup

RolloutHooks.on_startup (base):
- Reads AGL_POD_SPEC_TEMPLATE from env; if set, yaml.safe_load into self._pod_spec
- No-op when env var is absent (no FileNotFoundError, no KeyError)
- Docstring explains the two distinct files: AGL_JOB_MANIFEST_TEMPLATE (Jinja2
  scaffold used by controller) vs AGL_POD_SPEC_TEMPLATE (plain YAML used by hooks)
- Subclasses that only need per-sample customisation no longer need on_startup

examples/swe_bench/hooks.py:
- Remove on_startup override — base handles file loading now
- Remove SWE_POD_SPEC_TEMPLATE; use AGL_POD_SPEC_TEMPLATE instead

deploy/agl-lite.env.example:
- Expand controller section: document AGL_JOB_MANIFEST_TEMPLATE with its default,
  and AGL_POD_SPEC_TEMPLATE with description and example path
2026-04-01 08:31:22 -07:00
yuqyang 530a0d9dd6 chore(todo): mark job construction refactor [completed] 2026-04-01 08:16:21 -07:00
yuqyang cf5019a241 refactor: simplify job construction — RolloutConfig.pod_spec + hook on_startup
RolloutConfig:
- Remove image, command, environment_variables, mount, overrides, Mount class
- Add pod_spec: dict | None — full pod spec fragment assembled by on_enqueue hook
- Keep timeout and max_retries (Job-level execution policy)

RolloutHooks base class:
- Add on_startup(self, store) lifecycle method — called once at server startup
- Add copy_pod_spec() — deep copies self._pod_spec with nil-guard
- Add get_container(pod_spec, name) staticmethod — finds container by name
- Add _pod_spec convention and module-level docstring with usage pattern

server/app.py:
- Call hooks.on_startup(store) in create_app() after store is initialised

job_builder.py:
- Remove user_pod_spec param — reads rollout.config.pod_spec instead
- Remove _ensure_agent_container, _get_agent_container helpers
- Remove all named-field merge logic (image, command, env vars, mounts, overrides)
- build_job_spec(rollout, settings, manifest_template) — three steps only:
    1. render template → scaffold + patcher
    2. merge config.pod_spec into scaffold
    3. inject patcher env into all containers
    4. set backoffLimit / activeDeadlineSeconds

reconciler.py:
- Remove _resources_cache and _get_job_template — controller no longer
  fetches resources; pod spec arrives pre-assembled in rollout.config.pod_spec
- build_job_spec call simplified (no job_template arg)

examples/swe_bench/hooks.py:
- Rewrite using on_startup + copy_pod_spec + get_container pattern
- Load pod spec template from SWE_POD_SPEC_TEMPLATE env var at startup
- Set per-instance image and env vars directly in pod_spec.containers[0]
- Hoist activeDeadlineSeconds → config.timeout

tests: updated throughout (315 passing)
2026-04-01 08:16:05 -07:00
yuqyang 220fb45bc9 chore(todo): mark deploy .env conversion [completed] 2026-04-01 05:06:35 -07:00
yuqyang 35338f0d67 refactor: convert deploy config from YAML to .env format
- DeployConfig(BaseModel) -> DeploySettings(BaseSettings) with env_prefix='AGL_'
  and env_ignore_empty=True; loaded via pydantic-settings _env_file kwarg
- ServerRuntimeConfig wrapper removed; gateway_config, hooks, artifact_dir
  promoted to top-level AGL_GATEWAY_CONFIG, AGL_HOOKS, AGL_ARTIFACT_DIR
- Field renames to avoid double AGL_ prefix:
    agl_host_port -> host_port (AGL_HOST_PORT)
    agl_host_ip_bind -> host_ip_bind (AGL_HOST_IP_BIND)
    agl_base_url_k8s_accessible -> base_url_k8s_accessible (AGL_BASE_URL_K8S_ACCESSIBLE)
- CLI: --config -> --env-file on both deploy_entrypoint and deploy_command
- deploy/agl-lite.yaml.example -> deploy/agl-lite.env.example (.env format)
- .env file serves as single project config; extra vars (hook config, model
  endpoints, experiment params) are silently ignored by DeploySettings and
  consumed by other components via os.environ
- deploy/README.md, docs/deploy.md updated throughout
2026-04-01 05:06:22 -07:00
yuqyang 827e4fdc56 chore(todo): mark both refactor items [ready] 2026-04-01 05:01:48 -07:00
yuqyang ebfa78a8b3 docs(todo): clarify .env as single project config — extra vars ignored by DeploySettings 2026-04-01 05:00:40 -07:00
yuqyang 48d4a0b086 docs(todo): add two discuss items — job construction refactor and deploy .env conversion 2026-04-01 04:59:00 -07:00
yuqyang fa8b230543 docs: add coding preference — avoid abusing default values in system code 2026-04-01 04:11:33 -07:00
yuqyang 465a0233c8 fix: remove poll_interval/max_queue_time from CLI args — read from env vars
These are infra tuning values that live in deploy/controller/k8s.yaml as
AGL_POLL_INTERVAL and AGL_MAX_QUEUE_TIME. Passing them as explicit CLI
kwargs to ControllerSettings() overrides env vars (pydantic-settings init
priority), making the k8s.yaml entries silently dead.

Now ControllerSettings reads AGL_POLL_INTERVAL and AGL_MAX_QUEUE_TIME
directly from the pod environment via env_prefix='AGL_'.
2026-04-01 04:08:18 -07:00
yuqyang 41855545b7 feat: wire job_manifest_template through deploy — ConfigMap mount
deploy.py:
- Create ConfigMap agl-controller-job-template from the template file
  (cfg.job_manifest_template if set, else repo's packaged default
  deploy/controller/job-template.yaml.j2)

deploy/controller/k8s.yaml:
- Add --job-manifest-template /etc/agl/job-template.yaml.j2 to controller command
- Mount the ConfigMap at /etc/agl (readOnly)

The controller now always receives an explicit template path — no default
fallback anywhere in Python code.
2026-04-01 04:03:03 -07:00
yuqyang 56f84be193 chore: remove deploy/.env.example, update deploy config docs
- Remove deploy/.env.example (and untracked deploy/.env) — replaced by
  YAML-driven agl-lite deploy --config
- deploy/agl-lite.yaml.example: remove controller: block (poll_interval
  and max_queue_time now live in deploy/controller/k8s.yaml directly);
  add commented job_manifest_template field
- deploy/README.md: rewrite around YAML config workflow, remove .env
  references, update directory structure listing
- docs/deploy.md: remove controller.poll_interval_seconds and
  controller.max_queue_time_seconds from config table and example;
  add job_manifest_template row
2026-04-01 03:29:08 -07:00
yuqyang 584aa39d86 chore: manual doc edits to task-lifecycle.md, pin click<8.3.0 for mkdocs compat 2026-04-01 03:17:28 -07:00
yuqyang 317cbda491 docs: add task-lifecycle.md to mkdocs.yml nav 2026-04-01 01:33:14 -07:00
yuqyang 17ff78d832 docs: add task-lifecycle.md — end-to-end walkthrough from dataset row to trajectory
Covers all six stages with a Mermaid sequence diagram:
  1. Enqueue — input/resources_id/config split, on_enqueue hook
  2. Job construction — three-layer merge table (manifest_template /
     user_pod_spec / rollout.config) with merge precedence rules
  3. Pod startup — PodPatcher env injection, OPENAI_BASE_URL encoding
  4. Execution & capture — agent calls LLM, gateway captures transparently
  5. Completion — controller detects Job Complete, on_succeeded hook, reward
  6. Reading the trajectory — GET /api/events, triplet extraction for RL

index.md: add Task Lifecycle as first entry in 'What to read next' table,
add forward reference in 'How the pieces connect' section
2026-04-01 01:32:23 -07:00
yuqyang 6766e2f78c refactor: rename pod_spec param to user_pod_spec in build_job_spec
Eliminates name shadowing cleanly:
- user_pod_spec: input fragment from resources (user-provided, per-dataset)
- pod_spec: the working pod spec dict extracted from the rendered scaffold
  (the central mutable object throughout the function)
2026-04-01 01:14:07 -07:00
yuqyang f8159ba872 refactor: remove all default template paths — manifest path always explicit
- Remove _DEFAULT_MANIFEST_TEMPLATE_PATH and _load_manifest_template from
  reconciler.py; reconciler reads Path(settings.job_manifest_template).read_text()
  directly — no fallback, fails loudly if not provided
- ControllerSettings.job_manifest_template: str (required, no None default)
- cli.py: add --job-manifest-template required option to 'agl-lite controller'
- job_builder.py: rename local pod_spec variable to scaffold_pod_spec to
  eliminate shadowing of the pod_spec parameter
- tests: fixture reads deploy/controller/job-template.yaml.j2 directly;
  both settings fixtures supply job_manifest_template
2026-04-01 00:31:15 -07:00
yuqyang f074c1cd33 refactor: remove ControllerConfig, move poll/queue settings to k8s.yaml
- Remove ControllerConfig class from deploy.py
- Remove controller: ControllerConfig field from DeployConfig
- Promote job_manifest_template to top-level field on DeployConfig
- Drop AGL_POLL_INTERVAL and AGL_MAX_QUEUE_TIME from ConfigMap creation
  in deploy.py (they were the only reason ControllerConfig existed at
  deploy time)
- Hardcode AGL_POLL_INTERVAL=10 and AGL_MAX_QUEUE_TIME=3600 directly in
  deploy/controller/k8s.yaml as static value: entries — operator tunes
  them by editing the K8s manifest, not the deploy config schema
2026-04-01 00:13:36 -07:00
yuqyang 3482dfde10 feat: Jinja2 job manifest template + PodPatcher for controller
job_builder: Jinja2 template + PodPatcher [ongoing]

Refactor job_builder to move the hardcoded Job manifest structure and
controller env vars into a Jinja2 template with a PodPatcher schema for
per-container env/volume injection.

Design:
- deploy/controller/job-template.yaml.j2: two YAML documents separated by ---
  - Doc 0: Job manifest scaffold (apiVersion, kind, metadata, spec shell with
    restartPolicy: Never, empty containers/volumes)
  - Doc 1: PodPatcher — controller env vars (with Jinja2 variables) + volumes

- PodPatcher(BaseModel) in job_builder.py: validates doc 1 (env, volumes)

- build_job_spec() now takes manifest_template: str (Jinja2 template string).
  Merge order (later wins):
    1. Jinja2 manifest template (Job scaffold + PodPatcher defaults)
    2. job_template pod spec fragment from resources (containers, volumes, pod fields)
    3. rollout.config named fields -> agent container (image, command, env, mounts)
    4. rollout.config.overrides (name-matched container merge)
  PodPatcher env injected into ALL containers; container's own env wins on conflict.
  rollout.config.environment_variables further override patcher env on agent.

- load_manifest_template(path) helper: loads template string from path or packaged default
- ControllerSettings.job_manifest_template: path to custom template (None = packaged default)
- ControllerConfig.job_manifest_template: same for deploy config
- Reconciler loads template once at __init__ via load_manifest_template()
- jinja2 added to controller optional dependencies
- 45 tests updated and 8 new tests added (PodPatcher, merge helpers, env conflict)
2026-03-31 23:40:19 -07:00
yuqyang 17dffd7e1d feat(examples): add rollout archive operation at the end of each iteration 2026-03-31 18:59:28 -07:00
yuqyang 87ff4ba4e2 test: align math-poc with new deploy schema and separate experiment config 2026-03-31 18:38:12 -07:00
yuqyang 672a710662 update docs to clearly tell user's requirement 2026-03-30 21:35:40 -07:00
yuqyang d3b3fa43c0 small updates 2026-03-30 21:27:50 -07:00
yuqyang 081b093e6c refactor: align deploy flow and config with mode-based schema 2026-03-30 21:21:09 -07:00
yuqyang a2a28b4fc9 refactor: adopt AGL_BASE_URL naming and add deploy guide
- migrate deploy flow and configs from AGL_LITE_URL to AGL_BASE_URL naming
- update deploy schema fields to agl_base_url_* and local_state_dir semantics
- align controller/deploy/example wiring with base-url naming
- add docs/deploy.md with mode-specific host vs pod URL behavior
2026-03-30 09:24:17 -07:00
yuqyang ac0f5f5615 refactor: switch deploy config to typed YAML schema
- add DeployConfig pydantic schema in agl_lite/deploy.py
  with mode-aware validation and URL checks
- make AGL_KEY env-only (required at runtime)
- make deploy command config-driven: agl-lite deploy --config <yaml>
- remove deprecated CLI flags from deploy entrypoint
  (--controller-only, --no-serve, mode overrides)
- add deploy/agl-lite.yaml.example with self-explanatory fields
- keep scripts/deploy.sh as thin wrapper to Python deploy command

Validation:
- agl-lite deploy --help works
- py_compile succeeds
- 317 tests passed
2026-03-30 08:49:28 -07:00
yuqyang e037ddbba2 feat: add Python deploy entrypoint with --config support
Add  command as Python replacement for shell deploy logic:
- new module: agl_lite/deploy.py
- supports modes: --agl-in-k8s / --agl-in-host / --agl-external
- supports custom config path: --config <env-file>
- supports host launch controls: --agl-host-bind / --agl-host-port
- supports cleanup mode
- computes pod-facing and host-facing URLs, writes .local/agl-lite.env
- keeps backward-compatible aliases: --controller-only / --no-serve

Wire command into CLI (agl_lite/cli.py).
Convert scripts/deploy.sh into a thin wrapper that calls .

317 tests pass.
2026-03-30 08:21:49 -07:00
yuqyang 805f70eca8 refactor: split pod-facing vs host-facing agl-lite URLs in deploy.sh
- Introduce explicit URL roles:
  - AGL_LITE_URL_POD: used by controller/agent pods (written to configmap as AGL_LITE_URL)
  - AGL_LITE_URL: host-facing output for algorithms/debugging (written to .local/agl-lite.env)
- Add explicit external input variable:
  - AGL_LITE_URL_EXTERNAL for --agl-external mode (legacy AGL_LITE_URL still accepted)
- Keep backward compatibility for host mode input:
  - AGL_LITE_URL_POD or legacy AGL_LITE_URL
- Compute and print both URLs per mode; write to .local/agl-lite.env
- Keep host-service launch behavior unchanged in --agl-in-host mode
- Update header docs/table to reflect the new URL contract
2026-03-30 05:05:44 -07:00
yuqyang ae53bfe125 fix: make in-k8s serve honor hooks/gateway config and restore math-poc mock e2e
Follow-up fixes after deploy mode refactor:
- cli.serve now falls back to env vars (GATEWAY_CONFIG/HOOKS/ARTIFACT_DIR)
  when CLI args are empty, so K8s env-injected settings are respected
- deploy/agl-lite/k8s.yaml injects GATEWAY_CONFIG/HOOKS/ARTIFACT_DIR from configmap
- deploy.sh configmap generation supports runtime overrides safely (no set -e pipefail trap)
- include examples/ in agl-lite image (.dockerignore) so hooks/config files exist in pod
- math-poc/run.sh updates:
  - sync mode env to deploy/.env
  - pass relative gateway config path for in-pod access
  - use explicit --agl-in-k8s/--agl-in-host
  - use dedicated local port-forward port (default 18080)
  - auto-derive mock MODEL_ENDPOINT when unset

Validation:
- unit/integration tests: 317 passed
- E2E run: examples/math-poc/run.sh mock passed
  (10/10 rollouts succeeded, 10 reward events, 60% deterministic accuracy).
2026-03-30 03:48:01 -07:00
yuqyang 254fa161c4 refactor: refine deploy.sh modes with explicit external/host behavior
- Add explicit --agl-external mode (controller in K8s, server unmanaged)
- Add --agl-host-bind and --agl-host-port flags for host launch control
- Keep backward compatibility aliases: --controller-only/--no-serve => --agl-in-host
- Tighten URL validation:
  - non-minikube host mode requires explicit pod-reachable AGL_LITE_URL
  - external mode requires explicit non-localhost AGL_LITE_URL
- Avoid localhost default for remote clusters in host mode
- Generalize minikube DNS patch trigger for non-k8s modes using host.minikube.internal
- Improve final mode-specific output messages

Result: clearer deployment semantics across in-k8s / in-host / external setups.
2026-03-30 03:00:15 -07:00
yuqyang 3e28829410 feat: add explicit deploy modes --agl-in-k8s/--agl-in-host and auto-launch host service
- scripts/deploy.sh now uses explicit modes:
  - --agl-in-k8s (default): server + controller in K8s
  - --agl-in-host: controller in K8s + agl-lite server launched on host
- Keep backward-compatible aliases: --controller-only and --no-serve => --agl-in-host
- In host mode, deploy.sh now launches agl-lite serve automatically (nohup),
  writes PID/log under .local/, health-checks readiness, and stops prior managed instance
- cleanup mode now also stops managed host server PID if present
- Update example scripts/docs to prefer --agl-in-host flag

Result: after deploy.sh, agl-lite service is runnable in both modes.
2026-03-30 02:06:35 -07:00
yuqyang 462f23fe26 feat: add calc_x migration baseline and prioritize it for training parity
- Copy non-legacy calc_x files from original Agent Lightning into examples/calc_x/
  (calc_agent.py, eval_utils.py, train_calc_agent.py, README, tests)
- Mark calc_x migration as next primary Phase 5c action in dev/todo.md
- Update calc_x README header to indicate migration WIP and legacy files ignored

This establishes an apple-to-apple starting point for adapting calc_x to agl-lite
VERL training flow.
2026-03-30 01:54:42 -07:00
yuqyang ad26645c6d refactor: split math-verl training example from math-poc pipeline example
Create a dedicated training example at examples/math-verl/:
- train.py (moved from examples/math-poc/train_verl.py)
- run.sh (starts agl-lite serve on host, then runs training)
- .env.example (training-specific defaults)
- README.md (scope + usage)

Clarify boundaries:
- examples/math-poc/ is now explicitly data pipeline verification only
- VERL training integration lives in examples/math-verl/

Update todo references to new training path.
317 tests pass.
2026-03-30 00:57:13 -07:00
yuqyang cff633f309 refactor: make math-poc VERL training path vllm-only
- Remove mock/vllm mode switch from train_verl.py
- Always use examples/math-poc/vllm/job-template.yaml
- Update README training command and note that training is vLLM-only

No behavior change for vLLM path; mock mode is explicitly unsupported for training.
317 tests pass.
2026-03-29 22:41:00 -07:00
yuqyang 29033ea20c feat: add math-poc VERL training entry script and wire resources_id
- Add examples/math-poc/train_verl.py as first Phase 5c training entry
  - builds small VERL config
  - loads/splits GSM8K sample dataset
  - runs preflight checks (healthz/auth/resources)
  - optional smoke rollout check (completion + triplet extraction)
  - calls agl_lite.verl.entrypoint.run_ppo(...)
- Update math-poc README with training smoke-run command
- Wire resources_id through VERL path:
  - trainer passes config.agentlightning.resources_id into train_information
  - daemon enqueues rollouts with resources_id
- Update todo: mark training script item done; keep remaining preflight assertions open

317 tests pass.
2026-03-29 20:34:43 -07:00
yuqyang a85b4d7993 refactor: delete async_server.py, update __init__.py
async_server.py: Removed. The PatchedvLLMServer monkey-patched vLLM to
return token IDs — agl-lite gateway handles this via param injection
(return_token_ids: true in gateway-config.yaml).

__init__.py: Only export daemon (guarded imports). trainer/dataset/entrypoint
require torch/verl/ray and must be imported directly.

317 tests pass.
2026-03-29 20:11:20 -07:00
yuqyang 88d90755c2 refactor: adapt entrypoint.py + config.yaml — remove agentlightning deps
entrypoint.py:
- Drop imports: TraceAdapter, LLMProxy, LightningStore, Dataset
- Drop params from run_ppo() and TaskRunner: store, llm_proxy, adapter, daemon_cls
- Read agl_lite_url/agl_key from config.agentlightning (user-facing namespace)
- Pass agl_lite_url/agl_key to trainer constructor
- Hydra config_path: pkg://agl_lite/verl

config.yaml:
- Replace port with agl_lite_url + agl_key (env var defaults via OmegaConf)
- Remove custom_async_server block (gateway handles token ID injection)
- Keep agentlightning namespace (user-facing)

248 → 220 lines. 317 tests pass.
2026-03-29 20:10:35 -07:00
yuqyang 79c026d2ca refactor: adapt dataset.py — replace agentlightning.types.Dataset with Sequence
The original Dataset was just a Protocol with __getitem__ + __len__.
Replace with typing.Sequence[Any] — no behavioral change.
2026-03-29 20:08:42 -07:00
yuqyang ab6ca6a574 refactor: adapt trainer.py — use AglLiteDaemon internally
Remove agentlightning dependencies from trainer.py internals:
- Drop imports: TraceAdapter, TraceToTripletBase, LLMProxy, LightningStore
- Drop constructor params: store, llm_proxy, adapter, daemon_cls
- Add constructor params: agl_lite_url, agl_key
- Create AglLiteDaemon directly in fit() instead of daemon_cls factory
- Remove adapter isinstance check (gateway handles event→triplet)
- Remove v0/v1 mode selection (always HTTP)

Keep user-facing names unchanged:
- Class remains AgentLightningTrainer
- Config namespace remains agentlightning.*

Training logic (_train_step, _validate, _compute_reference_log_prob) unchanged.
549 → 530 lines. 317 tests pass.
2026-03-29 20:05:54 -07:00
yuqyang 3700d9c3a1 refactor: adapt daemon.py — replace AgentModeDaemon with AglLiteDaemon
Replace Agent Lightning's AgentModeDaemon (1155 lines) with AglLiteDaemon
(945 lines) that talks to agl-lite over HTTP via AglLiteClient.

Removed:
- All v0 legacy code (AgentLightningServer, Flask proxy server)
- All v1 store/proxy/adapter code (LightningStore, LLMProxy, TracerTraceToTriplet)
- agentlightning imports (6 import lines)
- _find_available_port(), _start_proxy_server_v0(), _update_proxy_server_v1()

Added:
- AglLiteClient for HTTP calls (register_models, enqueue_rollouts, get_events, get_rollout)
- Local Pydantic types (Triplet, Task, RolloutLegacy) replacing agentlightning.types
- Optional torch/verl imports (graceful degradation when not installed)

Kept unchanged:
- get_train_data_batch() (328 lines of tensor construction)
- Multimodal/mrope support
- Utility functions (padding, ids_startswith, _to_native)
- Validation and metrics (get_test_metrics, _validate_data)

Fix test: left_pad_truncate keeps last N tokens (Agent Lightning behavior).
All 317 tests pass.
2026-03-29 19:06:08 -07:00
yuqyang 7dd323d0b3 update for documents 2026-03-28 00:04:06 -07:00
yuqyang fe72b2019d chore: copy verl module from agent-lightning verbatim
Copy all files from agentlightning/verl/ into agl_lite/verl/ as-is:
- daemon.py (AgentModeDaemon, 1155 lines)
- trainer.py (AgentLightningTrainer, 549 lines)
- dataset.py (AgentDataset + LoadedDataset, 44 lines)
- entrypoint.py (run_ppo + TaskRunner, 248 lines)
- async_server.py (PatchedvLLMServer, 46 lines)
- config.yaml (Hydra defaults)
- __init__.py, __main__.py

Our previous AglLiteDaemon backed up to ~/tmp/agl_lite_daemon.py.
Next step: adapt to use agl-lite HTTP API, removing agentlightning deps.
2026-03-27 23:59:47 -07:00
yuqyang d2888593e0 run.sh: remove unnecessary pip-cache setup from mount section
Pip cache is in-VM only, DirectoryOrCreate handles creation.
2026-03-27 07:59:16 -07:00
yuqyang a5c3cf7aba swe_bench: minikube mount for host-accessible artifacts
hostPath in minikube (docker driver) is inside the VM, not the host.
run.sh now starts 'minikube mount' to bridge artifacts dir to host:
  host: ./artifacts/ (or AGL_ARTIFACT_DIR)
  VM:   /data/agl-artifacts

Also ensures pip-cache dir exists in VM for persistence.
Mount process cleaned up on exit.
2026-03-27 07:57:23 -07:00
yuqyang c3e9e476fe swe_bench: add pip cache hostPath volume for faster container startup
Mount /data/pip-cache → /root/.cache/pip so pip install swebench
only downloads on first run. Subsequent containers reuse cached
wheels from the shared hostPath (single-node minikube assumption).
2026-03-27 07:33:15 -07:00
yuqyang 06cba884c4 add AGL_ATTEMPT_ID env var (logical alias for AGL_POD_UID)
AGL_POD_UID is an infra concept (K8s pod UID), AGL_ATTEMPT_ID is
the agl-lite logical concept. Same value, cleaner abstraction for
container scripts.
2026-03-27 07:27:36 -07:00
yuqyang 80255e4548 swe_bench: move all event posting to grade.py, pure bash entrypoint
grade.py now takes output_dir and artifact_path as arguments:
  1. Reads patch.diff from output_dir, posts agent_output event
  2. Reads test_output.txt from output_dir, grades, posts reward event

entrypoint.sh is now pure bash (zero inline Python):
  Phase 1: install + run agent
  Phase 2: git diff → OUTPUT_DIR/patch.diff
  Phase 3: eval_script → OUTPUT_DIR/test_output.txt
  Phase 4: python3 grade.py OUTPUT_DIR artifact_path
  Phase 5: cp OUTPUT_DIR → ARTIFACT_DIR

Clean separation: bash owns files, Python owns events.
2026-03-27 07:24:14 -07:00
yuqyang deabe53444 swe_bench: remove patch_size from grade.py and reward event
patch_size is agent output metadata, already reported in the
agent_output event. Doesn't belong in the grading/reward path.
2026-03-27 07:17:44 -07:00
yuqyang 73617f82ba swe_bench: reorganize output flow, grade.py takes path argument
Output flow redesigned:
- All outputs written to local /tmp/agl_output/ first (patch.diff,
  test_output.txt)
- grade.py receives test_output path as CLI argument (no hardcoded path)
- grade.py is pure grading + reward posting (no file copying)
- Shell handles all file operations: archive to
  ARTIFACT_ROOT/rollout_id/attempt_id at the end
- agent_output event includes artifact_path (relative to ARTIFACT_ROOT)
  for downstream consumers to locate archived files
- Patch written to file instead of shell variable (avoids large string
  in memory)
2026-03-27 07:14:57 -07:00
yuqyang 1f33e3c245 swe_bench: install swebench explicitly in entrypoint via python3 -m pip
Move swebench installation from grade.py auto-install to entrypoint.sh.
Use 'python3 -m pip' for robustness — ensures pip matches the Python
that runs grade.py, regardless of conda env or pip/pip3 aliasing.
2026-03-27 05:04:15 -07:00
yuqyang be511e7be0 swe_bench: extract grading into grade.py, mounted via ConfigMap
Move inline Python grading + reward posting + archival from
entrypoint.sh into agents/grade.py. Cleaner, testable, maintainable.

entrypoint.sh phases 4/5/6 replaced with single line:
  python3 /agl/agents/grade.py "$PATCH_SIZE"

grade.py handles:
  - swebench get_eval_report() via SimpleNamespace test_spec
  - auto-install swebench if not present
  - POST reward event via urllib (no curl dependency)
  - archive test log to hostPath volume

Mounted alongside other agent scripts via ConfigMap.
2026-03-27 04:55:01 -07:00
yuqyang 94b709af23 todo: mark completed SWE-bench refactoring items 2026-03-27 04:40:11 -07:00
yuqyang 313a537b31 swe_bench: move grading into container, hostPath for artifacts
Architecture change:
- Container does agent → eval → grade (via official get_eval_report) →
  post small reward event (~200B) → archive test log to hostPath
- on_succeeded hook simplified to fallback-only: posts zero reward if
  container didn't post one. No file I/O, no grading logic.
- Artifact event posting removed from entrypoint (no large HTTP payloads)
- hostPath volume added to job-template.yaml for test log archival
- AGL_ROLLOUT_ID injected by controller for artifact path
- AGL_EVAL_META extended with repo/version for container grading
- Grading uses SimpleNamespace as minimal test_spec (avoids make_test_spec
  in container — only needs instance_id, repo, version, FAIL_TO_PASS,
  PASS_TO_PASS)

Files changed:
- examples/swe_bench/agents/entrypoint.sh: 6 phases (agent → patch →
  eval → grade → reward → archive)
- examples/swe_bench/hooks.py: on_succeeded simplified, removed
  _extract_patch/_find_artifact, added _has_reward_event
- examples/swe_bench/job-template.yaml: hostPath volume for artifacts
- examples/swe_bench/rl_loop.py: updated docstring
- agl_lite/controller/job_builder.py: inject AGL_ROLLOUT_ID
- tests/test_swebench_hooks.py: 12 tests rewritten for new architecture

317 tests passing.
2026-03-27 04:40:01 -07:00
yuqyang c89247e6ed todo: update SWE-bench design — grading in container, hostPath for artifacts
Key changes:
- (B) Container now does agent + eval + grading (not just agent + eval)
- (C) on_succeeded hook simplified — no file I/O, no grading logic
- (D) Artifacts written to hostPath volume for debugging, not through HTTP API
- Artifact event type in store no longer needed
- swebench package installed in container, not server
- Added E2E status section with verified items and remaining work
2026-03-27 04:31:11 -07:00
yuqyang bbcb514caf fix: pipe large artifact payload to curl via stdin
Test output can be 300KB+, exceeding shell argument length limits.
Use python to build JSON payload and pipe to curl --data-binary @-
instead of passing as -d argument.

Also update default model to Qwen3-4B-Thinking-2507, add
--tensor-parallel flag to start_vllm.sh.
2026-03-27 00:52:04 -07:00
yuqyang 349ae38c64 start_vllm.sh: enable tool use by default (hermes parser) 2026-03-26 23:13:27 -07:00
yuqyang a803cc64f0 start_vllm.sh: make max-model-len optional (use model default) 2026-03-26 23:12:19 -07:00
yuqyang b78d8a6bc2 start_vllm.sh: add --tool-call-parser flag for tool use support
Claude Code sends tool_choice: 'auto' which vLLM rejects unless
started with --enable-auto-tool-choice --tool-call-parser <parser>.

Usage:
  scripts/start_vllm.sh --tool-call-parser hermes  # Qwen2.5
  AGL_VLLM_TOOL_CALL_PARSER=hermes scripts/start_vllm.sh  # via env
2026-03-26 23:09:43 -07:00
yuqyang e3099b42fa todo: mark SWE-bench as [ongoing] 2026-03-26 23:05:38 -07:00
yuqyang 5248d7fe86 swe_bench run.sh: add cleanup step before each run
Kills old agl-lite server processes and deletes the namespace
(with all K8s resources) before starting fresh. Ensures clean
state — no stale secrets, old rollouts, or lingering pods.
2026-03-26 22:58:23 -07:00
yuqyang 3fc35e6ea3 revert: remove blanket controller restart from deploy.sh
deploy.sh should be idempotent and minimal. E2E test scripts
handle cleanup (namespace delete) before each run instead.
2026-03-26 22:58:05 -07:00
yuqyang b8fdd094d8 Revert "deploy.sh: use config hash annotation instead of blanket restart"
This reverts commit fc1b15aaf7.
2026-03-26 22:57:52 -07:00
yuqyang fc1b15aaf7 deploy.sh: use config hash annotation instead of blanket restart
Annotate controller deployment with sha256 of AGL_KEY + AGL_LITE_URL.
K8s triggers rolling update only when the hash changes (secret or
config actually changed). No unnecessary restarts on first deploy
or idempotent redeploys.
2026-03-26 22:55:45 -07:00
yuqyang 66093a4968 fix: add auth header to event POSTs, note vLLM tool-choice requirement
- entrypoint.sh: add Authorization header to curl POST for agent_output
  and artifact events (was getting 401 Unauthorized)
- vLLM requires --enable-auto-tool-choice and --tool-call-parser for
  Claude Code's tool use requests
2026-03-26 22:49:22 -07:00
yuqyang b71252ef98 fix: ANTHROPIC_BASE_URL without /v1, restart controller on redeploy
- ANTHROPIC_BASE_URL set to gateway_base (no /v1 suffix) — Anthropic SDK
  appends /v1/messages itself. Fixes double /v1 path issue.
- deploy.sh: restart controller deployment after apply to pick up
  secret/configmap changes.
- 317 tests passing.
2026-03-26 22:44:46 -07:00
yuqyang eee55e2d0c fix: add swebench/ Docker Hub prefix to SWE-bench image names 2026-03-26 20:13:36 -07:00
yuqyang 4479cb036e chore: default artifact dir to ./artifacts (project-local, gitignored) 2026-03-26 19:57:22 -07:00
yuqyang 9a5f046448 gitignore: add artifacts/ 2026-03-26 19:56:49 -07:00
yuqyang 392e09efbc swe_bench: default model to Qwen2.5-Coder-3B-Instruct for quick testing 2026-03-26 19:44:35 -07:00
yuqyang 74d4c3576c swe_bench: check vLLM model availability in run.sh
- Verify model server is reachable at AGL_MODEL_ENDPOINT
- Check AGL_MODEL_NAME is in the served models list (warn if not)
- Fix default agent from mini_swe_agent → claude_code
- Clean up env var exports
2026-03-26 19:36:51 -07:00
yuqyang b89d105599 feat: env var substitution in gateway config (${VAR_NAME})
Gateway config now supports ${VAR_NAME} syntax — resolved from environment
at load time via os.path.expandvars(). Useful for model name routing:

  routes:
    - model_in: "*"
      model_out: "${AGL_MODEL_NAME}"

SWE-bench gateway config updated to use this — Claude Code's model names
(e.g., claude-sonnet-4-20250514) get rewritten to the actual backend model.

2 gateway config tests added (317 total passing).
2026-03-26 19:33:02 -07:00
yuqyang e7aa70bacf refactor: remove mini_swe_agent, use claude_code only
Removed mini_swe_agent (will add other agents later).
Updated claude_code based on Agent Lightning's claude_code_controller.py:
- Prompt structure: heredoc approach for shell safety
- SWEBENCH_USER_PROMPT with 5-step instructions (reproduce, locate, fix, verify, cleanup)
- SWEBENCH_EXTRA_SYSTEM_PROMPT for expert context
- Claude CLI flags: --dangerously-skip-permissions, --output-format json, --verbose
- IS_SANDBOX=1, ANTHROPIC_AUTH_TOKEN setup
- Hook handler (handle_hook.sh) for capturing tool use events
- Claude settings.json with PreToolUse/PostToolUse/Stop hooks

Default agent changed from mini_swe_agent → claude_code.
315 tests passing.
2026-03-26 08:33:01 -07:00
yuqyang 307d5e6056 todo: mark SWE-bench example [completed] 2026-03-26 08:02:02 -07:00
yuqyang 7b6f7b690a feat: SWE-bench example — hooks, agents, rl_loop, deploy config
Complete examples/swe_bench/ implementation:

Hooks (hooks.py):
- on_enqueue: set per-instance Docker image, generate eval_script via
  make_test_spec(), inject env vars (AGL_TASK_INPUT, AGL_EVAL_SCRIPT, AGL_EVAL_META)
- on_succeeded: read test_output artifact from disk, grade via official
  get_eval_report(), post reward event (resolved/not resolved)
- on_failed: post zero reward event

Agent scripts:
- entrypoint.sh: shared entrypoint (agent → eval → post artifacts)
- mini_swe_agent: lightweight Python agent with OpenAI tool_use
- claude_code: Claude Code CLI wrapper

Infrastructure:
- rl_loop.py: task-agnostic algorithm (same pattern as math-poc)
- job-template.yaml: generic pod spec, image overridden by hook
- Dockerfile.server: agl-lite + swebench package
- run.sh: one-command E2E runner
- .env.example, gateway-config.yaml, README.md

Tests: 12 SWE-bench hook tests (mocked swebench), 315 total passing.
2026-03-26 08:01:56 -07:00
yuqyang 4e7e02853c feat: artifact event type — large files written to disk
Artifact events (event_type='artifact') are handled specially by the store:
- Content is written to disk at <artifact_dir>/<rollout_id>/<filename>
- Event data replaced with lightweight reference (path, filename, size)
- Hooks can read artifacts from disk (fits sync constraint)

New: --artifact-dir CLI flag and ServerSettings.artifact_dir
Default: /tmp/agl-artifacts

7 artifact tests added (303 total passing).

Needed for SWE-bench: container posts test_output.txt as artifact,
on_succeeded hook reads from disk and grades via get_eval_report().
2026-03-26 07:53:05 -07:00
yuqyang 2e9ac2a4be todo: resolve Q3 — ConfigMap for agent scripts 2026-03-26 07:50:46 -07:00
yuqyang cfa09ae395 todo: mark SWE-bench example [ready], resolve open questions 2 and 5 2026-03-26 07:45:10 -07:00
yuqyang 9b3163a4df deploy.sh: only patch CoreDNS when AGL_LITE_URL uses host.minikube.internal 2026-03-26 07:31:35 -07:00
yuqyang 58dc0cea12 update SWE-bench proposal + deploy.sh improvements
SWE-bench proposal (dev/todo.md):
- Grading in on_succeeded hook (not container): uses official get_eval_report()
- Artifact events for large files (test logs): store writes to disk, hook reads
- Container only runs agent + eval_script, posts raw artifacts
- Server on compute backend (--controller-only) as default deployment

deploy.sh:
- Rename --no-serve → --controller-only (clearer intent, --no-serve kept as alias)
- Auto-set AGL_LITE_URL for in-cluster mode (http://agl-lite.<ns>.svc:8080)
- Auto-patch CoreDNS on minikube for host.minikube.internal resolution
- Descriptive header comments for both deployment modes

Cleanup:
- Remove hostAliases hack from math-poc vllm/job-template.yaml
- Update deploy/.env.example with mode documentation
2026-03-26 07:29:20 -07:00
yuqyang bac897a316 todo: rewrite SWE-bench proposal for hooks architecture
Updated to reflect the implemented hooks mechanism:
- on_enqueue hook: set per-instance image, generate eval_script via
  make_test_spec(), inject env vars (AGL_EVAL_SCRIPT, AGL_EVAL_META)
- Grade inside container (not in hook): entrypoint runs agent → eval →
  grade.py → posts reward event. No shared volume needed.
- Algorithm is task-agnostic: sends raw dataset rows, reads reward events
- Removed stale 'Files to Create' for agl_lite/ core (already done)
- Reconciled container-vs-hook grading (decided: container)
- Trimmed open questions from 5+1 to 5 (resolved: grading location)
2026-03-26 01:35:54 -07:00
yuqyang 6606e926ea remove legacy code 2026-03-26 00:35:14 -07:00
yuqyang 6aea2f6ce7 cleanup: remove legacy math-poc files, rename v2 to final
Removed (replaced by mode subfolders):
  mock_rl_loop.py, rl_loop.py (old), run.sh (old)
  .env.mockai.example, .env.vllm.example
  gateway-config.yaml, job-template.yaml, k8s-mockai.yaml
  reference_output.log, reference_output_vllm.log

Renamed:
  rl_loop_v2.py → rl_loop.py
  run_v2.sh → run.sh

Final structure:
  examples/math-poc/
  ├── rl_loop.py        (unified, task-agnostic)
  ├── run.sh            (run.sh [mock|vllm])
  ├── Dockerfile.agent
  ├── README.md
  ├── agents/
  ├── data/
  ├── mock/             (hooks, .env, gateway-config, job-template, k8s-mockai)
  └── vllm/             (hooks, .env, gateway-config, job-template)
2026-03-26 00:30:26 -07:00
yuqyang abc6f44526 todo: confirm and remove Store Hooks + Math-poc restructuring
Both items verified via E2E. Added to Completed list.
Removed confirmed sections from todo (328 lines).
2026-03-26 00:29:39 -07:00
yuqyang e09180e8f9 fix: vllm hooks inject AGL_MODEL_NAME, job-template adds hostAliases
Two fixes found during E2E testing:
- vllm hooks.py: inject AGL_MODEL_NAME from server env into agent config
  (agent defaults to 'mock-llm' without it)
- vllm job-template.yaml: add hostAliases for host.minikube.internal
  (K8s pods can't resolve it via DNS, unlike minikube ssh)

E2E verified: 3/3 rollouts succeeded, hooks computed rewards correctly,
unified rl_loop_v2.py works end-to-end with vLLM mode.
2026-03-26 00:24:59 -07:00
yuqyang c351368305 fix: math-poc hooks respect job-template defaults, read ground_truth from input
(a) Hooks no longer override image -- it comes from job-template.
    Hook only sets config.environment_variables.AGL_TASK_INPUT.
(b) rl_loop_v2.py docstring documents algorithm contract:
    algorithm only sets input, resources_id, metadata per rollout.
(c) on_succeeded reads ground_truth from rollout.input directly,
    no copy to metadata needed.

296 tests passing, 7 math hook tests passing.
2026-03-25 23:55:32 -07:00
yuqyang b899c2f1b7 todo: mark math-poc restructuring as completed 2026-03-25 23:01:30 -07:00
yuqyang 6e875a1dc1 examples: math-poc restructured with hooks and mode subfolders
New structure:
  examples/math-poc/
  ├── rl_loop_v2.py          # unified, task-agnostic (~200 lines)
  ├── run_v2.sh              # run.sh [mock|vllm] (default: vllm)
  ├── mock/
  │   ├── hooks.py           # MathMockHooks: boxed embedding, exact match
  │   ├── .env.example
  │   ├── gateway-config.yaml
  │   ├── job-template.yaml
  │   └── k8s-mockai.yaml
  └── vllm/
      ├── hooks.py           # MathVllmHooks: plain questions, numeric reward
      ├── .env.example
      ├── gateway-config.yaml
      └── job-template.yaml

Hook responsibilities:
  on_enqueue: set image, AGL_TASK_INPUT, AGL_MODEL_NAME, stash ground_truth
  on_succeeded: extract answer from events, compute reward, post reward event

rl_loop_v2.py is fully task-agnostic: sends raw JSONL rows as input,
hooks do all transformation and grading. ~200 lines vs ~950 combined before.

7 new tests for mock + vllm hooks. 296 + 7 = 303 tests passing.
2026-03-25 23:01:23 -07:00
yuqyang b9bd98b850 todo: add math-poc restructuring with hooks [ongoing] 2026-03-25 22:56:50 -07:00
yuqyang 9dc3b80115 schemas: remove data field from RolloutMetadata
input already holds the raw dataset content. extra='allow' lets hooks
stash grading context (e.g., ground_truth) directly as extra fields
on metadata. No need for a reserved data dict.

RolloutMetadata is now minimal:
  batch_idx, sample_idx_in_batch, trial_idx_in_group + extra fields

296 tests passing.
2026-03-25 22:55:58 -07:00
yuqyang ec76bb7fd4 schemas: typed RolloutMetadata with algorithm control indexes
RolloutMetadata replaces dict[str, Any] with typed fields:
  - batch_idx, sample_idx_in_batch, trial_idx_in_group (algorithm tracking)
  - data: dict (raw dataset content for hooks to use in grading)
  - extra='allow' for task-specific extensions

Three fields now have clear consumers:
  input    → algorithm data (raw dataset row, read by hooks)
  config   → K8s controller (image, env vars, mounts)
  metadata → algorithm indexes + hook grading context (metadata.data)

296 tests passing.
2026-03-25 22:52:35 -07:00
yuqyang bd723cc86b refactor: remove AGL_TASK_INPUT auto-injection from job_builder
The controller no longer reads rollout.input — it only applies config.
Task input to the agent is now explicitly set by hooks via
config.environment_variables['AGL_TASK_INPUT'].

This cleanly separates:
  input    → algorithm data (raw dataset row, read by hooks)
  config   → K8s execution (env vars, image, mounts — set by hooks)
  metadata → algorithm control indexes (batch_idx, etc.)

Without hooks, agents get task from baked-in Docker image or from
config.environment_variables set directly by the caller.

296 tests passing.
2026-03-25 22:50:18 -07:00
yuqyang c690432db6 todo: mark Store Hooks as completed 2026-03-25 22:19:31 -07:00
yuqyang 163606fc99 feat: implement Store Hooks — rollout lifecycle hooks in server
RolloutHooks base class with 3 hook points:
- on_enqueue: pre-processor, transforms request before persist
- on_succeeded: post-transition, fires atomically after SUCCEEDED
- on_failed: post-transition, fires after TERMINAL_FAILED

Store integration (memory.py):
- __init__ accepts optional hooks parameter
- enqueue_rollouts: calls on_enqueue before creating each rollout
- update_rollout: calls on_succeeded/on_failed after status transition
- Hook errors are logged but don't crash the transition (except on_enqueue
  which prevents rollout creation — the request is invalid)

Infrastructure:
- agl_lite/hooks.py: RolloutHooks ABC + load_hooks() dynamic loader
- ServerSettings.hooks: path to hooks module
- CLI: agl-lite serve --hooks path/to/hooks.py
- create_app: loads hooks at startup, passes to InMemoryStore

12 new tests covering:
- on_enqueue: transforms request, passthrough without hooks, error prevents creation
- on_succeeded: reward posted atomically, wrong answer, hook error resilience
- on_failed: zero reward posted
- load_hooks: file loading, missing file, no subclass, multiple subclasses

296 tests passing (284 existing + 12 new).
2026-03-25 22:19:25 -07:00
yuqyang 6dfa1ce93b schemas: add metadata field to Rollout and EnqueueRolloutRequest
Hook-facing context (e.g., original dataset row, ground_truth, grading info).
Not sent to container — only accessible to store hooks for task-specific logic
like reward computation.

Three fields now have clear consumers:
  input    → agent (AGL_TASK_INPUT env var)
  config   → K8s controller (image, command, resources)
  metadata → hooks (dataset context, grading info)

284 tests passing.
2026-03-25 22:14:25 -07:00
yuqyang b8bdbb97b5 todo: finalize Store Hooks design — hooks as sync pre-processors in server
Replace TaskController (separate process) with RolloutHooks (in-server):
- on_enqueue: pre-processor, transforms request BEFORE persist
- on_succeeded: post-transition, runs inside update_rollout() atomically

Key insight: single-threaded sync store means hooks are atomic — no reader
can see intermediate state. No flags (reward_pending) or intermediate states
needed. Reward is in the store before update_rollout() returns.

User workflow: write hooks.py → build custom Docker image → done.
SWEBenchHooks: on_enqueue maps instance→image+eval_script,
  on_succeeded reads volume + calls official get_eval_report().

Updated SWE-bench file layout: add hooks.py, Dockerfile.server,
remove grade.py (grading now in server-side hook).
2026-03-25 20:54:51 -07:00
yuqyang 6212bc25f3 todo: Task Controller architecture — task-agnostic algorithm layer [discuss]
Key insight: separate task-specific logic (dataset parsing, image selection,
eval_script generation, official grading) from task-agnostic logic (model
registration, rollout polling, triplet→tensor construction).

TaskController interface:
  - prepare_rollouts(data, is_train) → List[EnqueueRolloutRequest]
  - compute_rewards(rollout_ids, volume_path) → Dict[str, float]

Volume-based grading: container writes test_output.txt + patch.diff to shared
volume. Algorithm-side TaskController reads files and calls official grading
tools (e.g., swebench get_eval_report). No grade.py needed in container.

Examples: SWEBenchController (~100 lines), MathController (~30 lines).
Daemon becomes task-agnostic (~300 lines). New task = new controller only.
2026-03-25 20:18:55 -07:00
yuqyang 2ce355d1ef todo: SWE-bench — single-container design (agent + eval in one rollout)
Key insight: eval_script only resets TEST files (from test_patch), not source
files. Agent's code modifications are untouched. So evaluation can run in the
same container right after the agent finishes.

- eval_script pre-generated by algorithm via make_test_spec() (~2KB bash)
- Passed to container via AGL_EVAL_SCRIPT env var
- entrypoint.sh: install agent → run agent → git diff → eval_script → grade → post reward
- grade.py: minimal log parser (~30 lines), no swebench package needed in container
- Eliminates second rollout, patch-passing, and Docker-on-host requirement
- Simplified file layout (no evaluation/ dir, no eval-job-template)
2026-03-25 19:01:09 -07:00
yuqyang 67089804a4 todo: update SWE-bench design — resolve (A)(B)(C)(D)(E) per discussion
(A) Use RolloutConfig.image (first-class field) instead of overrides
(D) Evaluation runs inside K8s as a second rollout, not on algorithm host
    - eval_script generated from swebench.harness.make_test_spec (pure Python)
    - evaluator job: apply patch + run eval_script + parse log + post reward
    - no Docker SDK needed on algorithm host
(C) ConfigMap for agent scripts + CLAUDE.md, mounted via existing Mount schema
(1) Naive image pull for now, IfNotPresent; Epoch AI trimmed images as fallback
(2) Volume mount via ConfigMap confirmed; open question on large files deferred
2026-03-25 00:50:36 -07:00
yuqyang 17bd0d5def todo: add SWE-bench example design [discuss]
Add examples/swe_bench design item covering:
- Per-instance SWE-bench Docker images via rollout overrides
- Pluggable coding agents (claude_code, mini_swe_agent) with install/run scripts
- Mountable config files (CLAUDE.md) via ConfigMap
- Reward function: separate evaluation container applies patch + runs golden tests
- Algorithm script structure following math-poc pattern with vLLM backend
- File layout and open questions for discussion
2026-03-25 00:11:57 -07:00
yuqyang ed3dd4a00d slides update 2026-03-22 22:36:34 -07:00
yuqyang 1966c8714c small changes 2026-03-22 08:48:37 -07:00
yuqyang 77392d321d docs: slides — add subtitle to Three Design Choices, split job-template into two pages 2026-03-22 08:41:07 -07:00
yuqyang e6859a0da1 docs: add job-template slide to Part 4 (store + controller)
Shows merge flow: job_template (raw pod spec) + controller injection +
rollout.config overrides → K8s Job. Two examples: simple math-poc
and multi-container coding tasks with scorer sidecar.
2026-03-22 08:36:37 -07:00
yuqyang a41979a06a docs: slides v3 — no animations, agent_output event, deployment demo, restructured
Changes:
- Remove all v-clicks/animations (technical discussion)
- Add agent_output as third reserved event type
- Move agent contract slide into gateway section
- Add deployment section with math-poc vLLM example
- Remove 'How You Can Help' page
- Use markdown image syntax for architecture diagram
- Add 7-item agenda matching new structure
2026-03-22 08:08:48 -07:00
yuqyang ee35412ed9 docs: rewrite refactor review slides — positive framing, no AL criticism
Restructured around three design choices (from README):
1. Self-owned gateway (replaces LiteLLM dep)
2. Gateway-level data capture (replaces OTEL dep)
3. K8s-native runner (store simplification as consequence)

Technical deep dives on gateway and store+controller.
Architecture diagram referenced from docs/images/.
No before/after comparisons with Agent Lightning.
2026-03-22 07:52:23 -07:00
yuqyang 16756d9569 docs: add refactor review slides for Agent Lightning developers
Slidev deck covering:
1. Why agl-lite (dependency problem, what we actually need)
2. Four key simplifications (LiteLLM, OTEL, Store, execution)
3. Architecture (high-level, data flow, weight updates)
4. VERL integration (AglLiteDaemon, triplet format, trainer code)
5. Developer guide (what lives where, agent contract, gateway config)
6. Status and next steps

21 content slides + 6 section dividers. ~25 min presentation.
2026-03-22 07:20:43 -07:00
yuqyang 51787311ce docs: update todo — Phase 5a+5b complete, 5c (full training loop) next 2026-03-22 07:05:45 -07:00
yuqyang 167e1ae9f6 feat: AglLiteDaemon — VERL trainer bridge via AglLiteClient
Phase 5a+5b: agl-lite side of VERL integration.

Server-side (5a):
- format=triplet on GET /api/events trims model_request to
  prompt_token_ids + response_token_ids, reward to scalar value
- AglLiteClient.get_events() accepts format param

Daemon (5b) — agl_lite/verl/daemon.py (851 lines):
  NEW (187 lines): store interaction via AglLiteClient
    - _async_set_up: register_models + enqueue_rollouts
    - _async_validate_data: get_events(format=triplet) → Triplet/RolloutLegacy
    - _async_run_until_finished: poll get_rollout for succeeded status
    - No proxy server, no adapter, no LightningStore
  COPIED (510 lines): from agent-lightning AgentModeDaemon
    - get_train_data_batch: triplets → padded tensors → DataProto
    - Multimodal (mrope, image handling)
    - Utilities (padding, token matching)
    - Validation/metrics

Tests: 9 new (5 utility, 4 daemon with real agl-lite server via ASGI transport)
Total: 284 tests passing
2026-03-22 07:04:34 -07:00
yuqyang 7901c3b39a feat: triplet format on GET /api/events?format=triplet
Adds format=triplet query param that trims events for RL training:
- model_request: extracts prompt_token_ids + response_token_ids from
  streaming (list of SSE chunks) or non-streaming (dict) responses,
  strips full request/response bodies
- reward: keeps only scalar value, strips message
- other event types: pass through unchanged

No new endpoint — same auth, filtering, pagination. Raw events still
available without the flag.

4 new tests (streaming, non-streaming, no token_ids, full-event baseline).
275 total tests passing.
2026-03-22 06:55:44 -07:00
yuqyang 805d1454c7 docs: fix Phase 5b framing — agent-lightning depends on agl-lite HTTP, not vice versa
Corrected dependency direction: agl-lite is a standalone HTTP service with no
VERL/torch knowledge. The daemon subclass (Option A) lives in agent-lightning
repo, talks to agl-lite over HTTP. Recommended Option A (~150 lines) over
Option B (~650 lines with copied tensor math).
2026-03-22 06:36:20 -07:00
yuqyang 7255d65220 docs: Phase 5 design — triplet API + VERL integration options A/B
Detailed daemon breakdown (1154 lines: 30% replace, 63% reuse, 6% simplify).
Phase 5a: triplet API in agl-lite (events→triplets server-side).
Phase 5b: two options for VERL-side — daemon subclass vs standalone interface.
Phase 5c: full training loop E2E.
2026-03-22 06:21:46 -07:00
yuqyang 9fa477335c docs: update todo — Phase 4b with colocated topology, gateway injection, new decisions 2026-03-22 05:17:31 -07:00
yuqyang f6215e2eb5 refactor: colocate agl-lite with algorithm on host in vLLM mode
Topology change for vLLM mode:
  Before: agl-lite in minikube pod, port-forward to host, gateway→vLLM
          crosses minikube↔host network boundary
  After:  agl-lite on host (process), gateway→vLLM is localhost,
          no port-forward needed, only controller+agents in minikube

Changes:
- deploy.sh: --no-serve flag skips agl-lite Deployment (controller-only)
- run.sh: vLLM mode starts 'agl-lite serve' as host process with gateway
  config, waits for healthz; mock mode unchanged (all in K8s + port-forward)
- .env.vllm.example: AGL_LITE_URL=host.minikube.internal:8080 (K8s→host),
  AGL_MODEL_ENDPOINT=localhost:8010 (gateway→vLLM, both on host)
- README: updated architecture diagram showing colocated topology
- Both modes E2E verified: mock 10/10, vLLM 4/5 (80%) all checks pass
- 271 unit tests passing
2026-03-22 05:08:22 -07:00
yuqyang f6119deac9 feat: gateway param injection — return_token_ids for RL training
- gateway-config.yaml: wildcard route adds return_token_ids=true to all requests
- run.sh: in vLLM mode, creates ConfigMap from gateway config and patches
  agl-lite deployment with volume mount + --gateway-config flag
- proxy: event captures prepared body (with injected params), not original
  — RL algorithm sees exactly what was sent to model server
- rl_loop.py: logs prompt_token_ids + response token_ids from events,
  2 new structural checks (return_token_ids in request, token_ids in response)
- Verified: 102 prompt tokens + 253 response tokens captured per rollout
- Updated reference_output_vllm.log with token_ids
- 271 unit tests passing
2026-03-22 03:40:57 -07:00
yuqyang 46f7214e7e docs: update todo — Phase 4b complete, performance baseline to backlog 2026-03-22 03:23:34 -07:00
yuqyang 45fb153b65 feat: vLLM E2E verified — reference log + both modes documented
- reference_output_vllm.log: Qwen2.5-1.5B-Instruct, 5/5 correct (100%)
  Real math reasoning with \boxed{} parsing, 288 SSE chunks per response
- README: verify section for both modes, files table updated
- vLLM E2E: all 7 checks pass (rollouts, events, version, structural, accuracy)

Two reference logs:
  reference_output.log      — mock mode (deterministic, avg reward 0.60)
  reference_output_vllm.log — vLLM mode (real inference, 5/5 = 100%)
2026-03-22 03:20:07 -07:00
yuqyang be5fa1a333 feat: rl_loop.py for real vLLM inference + updated docs
- rl_loop.py: real algorithm for GSM8K with vLLM
  - Plain questions as input (no \boxed{} embedding trick)
  - Numeric reward: normalize_number() handles 18/18.0/$18/18,000
  - Structural checks on model_request events (streaming)
  - Sanity check: at least some correct answers expected
  - Logs first model_request with actual LLM reasoning
- Agent: --model defaults to AGL_MODEL_NAME env var (no hardcoded model)
- job-template: command is just 'python /app/qa_agent.py' (model from env)
- mock_rl_loop: passes AGL_MODEL_NAME via environment_variables in config
- README: rewritten with both architectures, vLLM Docker setup, start_vllm.sh
  usage, environment details, file index
- Mock mode E2E verified: still passes all checks
2026-03-22 03:12:01 -07:00
yuqyang 2d1f094ad8 feat: vLLM setup — Docker-based, convenience script, verified E2E path
- scripts/start_vllm.sh: start/stop vLLM in Docker container
  --model, --port, --gpu, --gpu-mem, --max-model-len, --stop
  Waits for health check, prints connection info for host + minikube
- .env.vllm.example: updated with Docker settings (port 8010, GPU 0,
  gpu-memory-utilization 0.2), start_vllm.sh usage in header
- Verified: vLLM 0.18.0 (vllm/vllm-openai:latest Docker image)
  - Qwen2.5-1.5B-Instruct serving on GPU 0 (shared machine, 12GB free)
  - Real math reasoning with \boxed{} format works
  - Reachable from minikube via host.minikube.internal:8010
- pip install approach failed (triton/gcc compilation issue with CUDA 13);
  Docker image works reliably
2026-03-22 02:51:47 -07:00
yuqyang dba4531fa3 refactor: two self-contained .env examples, mode-aware run.sh
- .env.mockai.example: complete config for mock mode (CPU-only minikube)
- .env.vllm.example: complete config for vLLM mode (host GPU)
- User copies one to deploy/.env — single config file, everything in one place
- run.sh: reads AGL_MODEL_MODE, skips mockai deploy in vllm mode,
  runs mock_rl_loop.py or rl_loop.py accordingly, checks vLLM reachable
- mock_rl_loop.py: reads BATCH_SIZE/NUM_ITERATIONS/MODEL_NAME/MODEL_ENDPOINT
  from env (sourced from .env via run.sh)
- README: updated quick start to show the two-file workflow
- Removed old examples/math-poc/.env.example (replaced by mode-specific files)
- E2E verified: mock mode still passes all checks
2026-03-21 23:50:49 -07:00
yuqyang 972d646771 docs: math-poc .env.example + README with architecture diagrams
- .env.example: PoC-specific config (MODE, MODEL_NAME, VLLM_PORT, etc.)
- README: mock vs vLLM architecture diagrams, event flow table,
  quick start for both modes, environment details table, file index
- Separates infra config (deploy/.env) from PoC config (examples/math-poc/.env)
2026-03-21 23:31:33 -07:00
yuqyang 017ef879d7 docs: clean todo — Phase 4a complete, 4a.7 to backlog, ready for 4b 2026-03-21 23:19:09 -07:00
yuqyang f965fd9083 feat: verify model_request events in E2E — streaming mode, full structure
- Agent now uses stream=True (OpenAI SDK streaming)
- mock_rl_loop: prints first model_request event in full detail:
  server (model, version, endpoint), request (model, stream, messages),
  response (SSE chunk count, first chunk, assembled content)
- 10 structural assertions per model_request event:
  has request/response/server, has model, ≥2 messages, stream=True,
  has version, response is list (SSE), response non-empty
- All 10 rollouts × 10 checks = 100 assertions pass
- reference_output.log updated with streaming sample + redacted IDs
2026-03-21 23:14:52 -07:00
yuqyang 54065e7621 feat: embed \boxed{} in task input for deterministic reward testing
- build_tasks: alternating pattern (even=correct, odd=wrong)
- Correct tasks: embed \boxed{ground_truth} → reward=1.0
- Wrong tasks: embed \boxed{WRONG} → reward=0.0
- Assert agent_answer matches embedded boxed value exactly
- Assert reward matches expected (1.0 or 0.0)
- Log tags: ✓/✗ per task and per result
- Expected avg reward: 0.60 (3/5 correct per batch of 5)
- All 7 checks pass including new 'Answer assertions' check
- Updated reference_output.log with new format
2026-03-21 23:02:59 -07:00
yuqyang ea75bc4921 feat: add logging to run.sh + reference output log
- run.sh: tee all phases to logs/<timestamp>/ directory
- Cleanup collects K8s logs: agl-lite, controller, mockai, agent pods, pods/jobs
- reference_output.log: redacted (rollout IDs → <rollout-id>) for user comparison
- logs/ dir gitignored, reference_output.log explicitly un-ignored
2026-03-21 22:45:15 -07:00
yuqyang 2ba3aa4d03 feat: Math PoC E2E working — 10/10 rollouts, all events, version tracking
Fixes from live testing:
- build_images.sh: -f flag relative to build context for Dockerfile.agent
- run.sh: export AGL_KEY so child processes see it
- controller k8s.yaml: pass --agl-lite-url, --namespace, --secret-name as args
  (CLI defaults override env vars with Typer)
- mock_rl_loop.py: deterministic task selection (sequential, no randomness),
  detailed reproducible logs (no timestamps, tempo order)

E2E results:
- 2 iterations, 5 rollouts each, all 10 succeeded
- 10 model_request (gateway auto), 10 agent_output (agent), 10 reward (algorithm)
- Version 1 in iter 1, version 2 in iter 2 (weight update verified)
- Reward 0.00 (expected — mockai echo mode echoes question, no \boxed{})

271 unit tests passing.
2026-03-21 21:35:24 -07:00
yuqyang 7969c1d3e5 refactor: image + command in job-template.yaml, not per-rollout
- job-template.yaml now includes image, command, imagePullPolicy
- RolloutConfig.image changed from required to optional (str | None)
- job_builder: only set image/command if RolloutConfig provides them
- mock_rl_loop.py: config={} — all agent config comes from template
- Simpler: template = complete pod environment, rollout = just task input
2026-03-21 21:11:19 -07:00
yuqyang 49d97afb98 refactor: agent takes model as CLI arg, task input as plain string
qa_agent.py:
- Model via --model arg (set in RolloutConfig.command), not in task input
- System prompt as separate message (avoids \boxed{} escaping in f-strings)
- Task input is a plain text question string (json.loads → str)

mock_rl_loop.py:
- AGENT_COMMAND includes --model flag
- Task input is plain string (the question), not a dict
- Matching by rollout.input string for ground truth lookup

Schema changes:
- rollout.input: dict → Any (supports string, dict, or any JSON-serializable)
- EnqueueRolloutRequest.input: dict → Any

271 tests passing.
2026-03-21 21:08:33 -07:00
yuqyang 9684a190dc feat: redesign agent + algorithm event flow
qa_agent.py rewritten:
- Prompt template: math assistant with \boxed{answer} format
- Task input: dict with 'question' field (plain text problem)
- Parses \boxed{answer} from LLM response
- Posts agent_output event to AGL_EVENT_URL with extracted answer
- Uses httpx (bundled with openai SDK) for event posting

mock_rl_loop.py rewritten:
- Passes plain questions as task input (no embedded answers)
- Retrieves agent_output events to get parsed answers
- Compares to ground truth, posts reward events
- Verifies all three event types: model_request, agent_output, reward

Reserved event types:
- model_request: auto-captured by gateway
- agent_output: reported by agent (parsed result)
- reward: reported by algorithm (ground truth comparison)

271 tests passing.
2026-03-21 21:00:27 -07:00
yuqyang 6c4d8a017d refactor: move agents into examples/math-poc/agents/ (PoC-specific, not shared) 2026-03-21 20:47:52 -07:00
yuqyang 9f14cfcca6 feat: Math PoC — mock RL loop with mockai on minikube
examples/math-poc/:
- mock_rl_loop.py: 2-iteration RL loop (register resources/models, enqueue
  batches, poll, retrieve events, compute rewards, weight update v1→v2)
- job-template.yaml: K8s pod spec for agent jobs (imagePullPolicy: Never)
- Dockerfile.agent: python:3.12-slim + openai SDK + qa_agent.py
- k8s-mockai.yaml: mockai Deployment + Service (echo mode)
- data/gsm8k_sample.jsonl: 30 GSM8K problems
- run.sh: one-command E2E (build images → deploy infra → deploy mockai →
  port-forward → run algorithm → cleanup)
- README.md: explanation + quick start

Dataset design: algorithm embeds correct/wrong answers in prompts,
mockai echoes back, reward function parses and compares to ground truth.

job_builder.py: updated to accept direct pod spec format (no spec: wrapper).

271 tests passing.
2026-03-21 20:18:21 -07:00
yuqyang e52af54a60 docs: clean todo — condense completed 4a.1-4a.4b into summary 2026-03-21 19:57:46 -07:00
yuqyang 80723d8e72 refactor: job_defaults → job_template (raw K8s pod spec)
Breaking changes:
- Removed JobDefaults typed schema and K8sResources from resources.py
- Reserved key in resources changed from 'job_defaults' to 'job_template'
- job_template is an opaque raw dict — any valid K8s pod spec field works
- No validation at store level; K8s validates when controller submits Job

RolloutConfig changes:
- Added 'overrides: dict[str, Any]' field for per-rollout K8s overrides
- Named fields (image, command, env_vars) target 'agent' container
- overrides.containers enables name-matched merge into other containers

job_builder.py rewritten:
- Starts from job_template as base pod spec (deep copy, not mutate)
- Applies rollout.config.overrides (name-matched container merge)
- Injects named fields into 'agent' container (finds or creates it)
- Injects controller env vars (gateway URLs, keys, task input)
- Wraps in Job metadata (name, namespace, labels, backoffLimit, ttl)

reconciler.py updated:
- _get_job_defaults → _get_job_template (returns raw dict)
- Error classification: invalid spec (422) → terminal_failed,
  transient errors → stay queuing and retry

Tests rewritten:
- test_job_builder.py: 35 tests covering template passthrough, multi-container,
  name-matched overrides, env injection, mounts, deep merge
- test_resources.py: simplified (opaque dict, no schema validation)
- test_reconciler.py: updated caching tests for job_template

271 tests passing.
2026-03-21 09:09:43 -07:00
yuqyang 5b60310abc docs: architecture + todo — job_template design, error handling, use cases
Architecture doc updated:
- job_defaults → job_template (raw K8s pod spec, no typed schema)
- Merge order: template → overrides (name-matched containers) → named fields → controller
- Three use cases documented: simple, multi-container sidecar, per-task multi-image
- Error handling: invalid spec → terminal_failed, resource shortage → stay queuing
- RolloutConfig gains overrides field (targets other containers via name match)

Todo 4a.4b updated with error handling requirements.
2026-03-21 09:01:36 -07:00
yuqyang 495de26769 docs: add 4a.4b — refactor job_defaults → job_template (raw K8s spec)
Blocking issue for 4a.5: current JobDefaults schema is ad-hoc typed
fields + overrides escape hatch. Replace with raw K8s pod spec dict
loaded from YAML file. No schema validation at store level.

Key changes planned:
- Remove JobDefaults typed schema
- job_template = raw dict (any valid K8s field)
- overrides moves to RolloutConfig (per-rollout escape hatch)
- deploy/job-template.example.yaml for infra team
- Simplified merge: template → overrides → named fields → controller
2026-03-21 08:24:55 -07:00
yuqyang e507e1eee3 feat: qa_agent.py — minimal example agent for E2E testing
examples/agents/python/qa_agent.py:
- Reads AGL_TASK_INPUT (JSON with prompt), calls LLM via openai SDK, prints result
- openai SDK reads OPENAI_BASE_URL + OPENAI_API_KEY from env (set by controller)
- max_retries=5 for 503 handling during weight updates
- CRASH_ON_FIRST=1 support: marker file at /tmp so only first attempt crashes
  (for K8s Job retry testing)
- Does NOT import agl-lite — proves language-agnostic contract
- ~50 lines, zero agl-lite dependencies
2026-03-21 08:02:21 -07:00
yuqyang b93d74c433 chore: ignore .env in all subdirs for both git and docker 2026-03-21 07:56:43 -07:00
yuqyang 16a1dd6b27 revert: back to bash + .env, remove Python deploy and YAML config
deploy/.env.example: flat KEY=VALUE, all config in one file.
  AGL_KEY commented out as placeholder — user sets via env var or uncomments.
scripts/deploy.sh: ~80 lines bash, reads .env, creates ConfigMap via
  --from-env-file (excluding AGL_KEY), Secret via --from-literal.
Removed: scripts/deploy.py, deploy/config.example.yaml, volume mounts.
K8s manifests simplified — no config file mounts, just env vars from
  ConfigMap (configMapKeyRef) and Secret (secretKeyRef).
271 tests passing.
2026-03-21 07:54:42 -07:00
yuqyang b5624a5123 chore: add --cleanup as alias for --teardown in deploy.py 2026-03-21 07:41:47 -07:00
yuqyang c36ac5afd9 refactor: deploy.py replaces deploy.sh, remove .env file
- deploy/config.yaml is the single config source (k8s, controller, serve sections)
- AGL_KEY is env var only — never on disk, no .env file
- deploy.py (Python): reads YAML, flattens k8s + controller sections into
  ConfigMap literal keys, includes full YAML as file key for volume mount
- Removed deploy/.env.example and scripts/deploy.sh
- Smoke tested: deploy + teardown both work on minikube
- 271 tests passing
2026-03-21 07:34:14 -07:00
yuqyang 7795f6c38b fix: config.yaml stays YAML, deploy.sh replaces deploy.py
- config.example.yaml: proper YAML (not flat env vars)
- deploy.sh: --from-file for YAML + --from-literal for extracted values
  (AGL_LITE_URL, AGL_SECRET_NAME parsed from YAML, AGL_K8S_NAMESPACE from .env)
- AGL_K8S_NAMESPACE lives only in .env — single source of truth
- deploy.sh supports --teardown flag
- Smoke tested: deploy + teardown both work on minikube
2026-03-21 06:59:27 -07:00
yuqyang ca8ab4cae3 feat: Phase 4a.3 — deploy structure, Dockerfile, K8s manifests, build script
deploy/agl-lite/:
- Dockerfile (python:3.12-slim + uv + pip install .[controller])
- k8s.yaml (Deployment + Service, env from Secret, config from ConfigMap)

deploy/controller/:
- k8s.yaml (reuses agl-lite:dev image, different command)
- rbac.yaml (ServiceAccount + Role + RoleBinding)

deploy/:
- .env.example (AGL_KEY + AGL_K8S_NAMESPACE)
- config.example.yaml (AGL_LITE_URL, controller settings)
- README.md (manual deploy instructions)

scripts/build_images.sh — unified image builder (minikube image build)
.dockerignore — excludes .venv, .git, tests, docs, dev, examples, etc.

Smoke tested: deployed to minikube, both pods Running,
healthz OK, agl-client queries working via port-forward.

271 tests passing.
2026-03-21 06:39:57 -07:00
yuqyang 05f350747e feat: agl-client CLI — separate entrypoint for API consumers
agl_lite/client_cli.py:
- Typer app with subcommand groups: rollouts, events, models, resources, health
- rollouts: list (with status/id filters), get, cancel
- events: list (by rollout_id, optional attempt_id/event_type filters)
- models: list, register, delete, delete-all
- resources: get, latest, add (JSON string or @filepath)
- health: check /healthz endpoint
- Reads AGL_LITE_URL and AGL_KEY from env vars
- JSON output for all queries (pipe-friendly)

pyproject.toml: agl-client = agl_lite.client_cli:app

tests/test_client_cli.py:
- 7 integration tests against real FastAPI server (subprocess)
- Tests: health, models CRUD, resources add+latest, rollouts list, events validation

271 tests total (264 + 7 new). All passing.
2026-03-21 06:36:01 -07:00
yuqyang fa450dce8e docs: rename client CLI to separate entrypoint agl-client
agl-lite = infra operator (serve, controller)
agl-client = API consumer (rollouts, events, models, resources)
2026-03-21 06:32:55 -07:00
yuqyang 372eb1de6b docs: update architecture + todo for list-based gateway routes with wildcards
- Architecture doc: added wildcard rules (model_in: *, model_out: *),
  priority ordering note, updated frozen decisions
- Todo: updated Gateway config frozen decision to reflect list-based format
2026-03-21 06:28:54 -07:00
yuqyang c49a999782 refactor: gateway config — list-based routes with wildcard support
Breaking change to gateway YAML format:
  Old: dict-based {model_in: {model: model_out, params: ...}}
  New: list-based [{model_in: ..., model_out: ..., params: ...}]

New features:
- Priority ordering: first match wins (list order)
- Wildcard model_in: '*' matches any unmatched model
- Wildcard model_out: '*' means passthrough (keep original name)
- Combines: '*' → '*' with params = global param adjustment

Files changed:
- agl_lite/gateway/config.py — RouteConfig gains model_in, GatewayConfig.routes is list
- agl_lite/gateway/router.py — resolve() iterates list, checks exact then wildcard
- agl_lite/server/app.py — log num_routes instead of route keys
- tests/gateway/test_config.py — rewritten for new format + wildcard tests
- tests/gateway/test_router.py — added wildcard resolve tests
- tests/gateway/test_proxy.py — updated YAML fixture

264 tests passing.
2026-03-21 06:16:47 -07:00
yuqyang 0b6985d1b0 docs: dataset design — embed answers in prompt, echo mode + real reward parsing
No mockai modification needed. Algorithm embeds correct/wrong answers
in prompt, mockai echoes back, reward function extracts and compares.
Mix of reward 1.0/0.0, fully verifiable end-to-end.
2026-03-21 05:48:40 -07:00
yuqyang 11cd7928e6 docs: Phase 4a — all-in-K8s topology, Python deploy scripts
Decisions #17-18:
- All-in-K8s for Phase 4a (serve, controller, mockai in cluster)
- Only algorithm script on host via port-forward
- Python for orchestration (deploy.py, run.py), bash for image builds
- Scripts in scripts/, invoked from repo root
2026-03-21 05:16:38 -07:00
yuqyang 9e0d9f168d update installing manual 2026-03-21 05:06:11 -07:00
yuqyang 7563176679 docs: split config into .env (secrets+bootstrap) and config.yaml (structured)
.env: AGL_KEY + AGL_K8S_NAMESPACE (bootstrap, used by setup script)
config.yaml: serve host/port, agl_lite_url, controller settings (mounted as ConfigMap volume)
CLI gets --config flag. Precedence: config file → env vars → CLI args.
2026-03-21 03:22:05 -07:00
yuqyang 630a7d6b2a docs: resolve namespace (option C) and secret handling (.env → K8s, no disk)
Decision #14 updated: AGL_KEY via --from-literal (never on disk),
  ConfigMap via pipe to --from-env-file=/dev/stdin.
Decision #16 added: manifests omit namespace, setup applies with -n.
2026-03-21 02:55:04 -07:00
yuqyang ba6cc3ad64 docs: Phase 4a — .env → ConfigMap/Secret pattern, no deploy/common/
Decisions added:
- #14: .env.example as single config source. Setup script creates K8s
  Secret (AGL_KEY) + ConfigMap (AGL_LITE_URL, etc.) from .env.
  Manifests use valueFrom — no hardcoded values.
- #15: Dockerfile uses uv for installs. .dockerignore excludes
  .venv, .git, tests, docs, dev, examples, node_modules, tmp, .local.
- Removed deploy/common/ — setup script handles namespace + secret + configmap.
- Removed gateway-config.yaml from deploy (passthrough for PoC, discuss later).
2026-03-21 02:47:49 -07:00
yuqyang 3ce443d865 refactor: organize tests by environment — unit vs e2e/cpu vs e2e/gpu
- Move kr8s adapter tests to tests/e2e/cpu/
- Default pytest run excludes e2e (--ignore=tests/e2e)
- Explicit: uv run pytest tests/e2e/cpu -v  (needs K8s cluster)
- Future:   uv run pytest tests/e2e/gpu -v  (needs K8s + GPU)
- No distinction between minikube/real K8s — split by capability required

Test categories:
  tests/         — 254 unit tests, all mocked, ~5s, every commit
  tests/e2e/cpu/ — 9 integration tests, needs K8s cluster, ~30s
  tests/e2e/gpu/ — placeholder for Phase 4b (needs GPU + vLLM)
2026-03-20 21:47:53 -07:00
yuqyang 472cf4ce2c feat: Kr8s adapter — real K8s client implementing K8sClient protocol
agl_lite/controller/kr8s_adapter.py:
- Kr8sClient wrapping kr8s async API (create/delete/get/list jobs+pods, watch)
- Kr8sJobWatcher async iterator yielding (event_type, raw_dict) tuples
- Idempotent delete (ignores NotFoundError)
- Lazy API init (connects on first use)

tests/controller/test_kr8s_adapter.py:
- 9 integration tests against real minikube (agl-test namespace)
- Tests: create+get, not-found, delete, idempotent delete, list jobs,
  list pods, watch events, job complete, job fail

263 tests total (254 existing + 9 new). All passing.
2026-03-20 21:44:20 -07:00
yuqyang 7efbda7dfa docs: Phase 4a — GSM8K dataset, unified build script, agent image convention, ordering discussion
Decisions added:
- #11: GSM8K 30-problem subset for math-poc dataset
- #12: Image = environment, config.command = task selector
- #13: Dockerfile.agent build context = examples/
- #5 updated: unified scripts/build_images.sh (bash, minikube)
- 4a.6 updated: build_images.sh + .dockerignore
2026-03-20 21:36:57 -07:00
yuqyang bf793c7ae0 docs: move agent Dockerfile to PoC (examples/math-poc/Dockerfile.agent)
Agent source in examples/agents/python/ is pure code — reusable templates.
Each PoC owns its Dockerfile since it may include data, tools, extra deps.
2026-03-20 21:18:31 -07:00
yuqyang 56eb814780 docs: agents + mockai stay in examples/, deploy/ is infra only
- deploy/ = agl-lite service, controller, common K8s resources
- examples/agents/python/ = agent source + Dockerfile (task-specific)
- examples/math-poc/ = full PoC scenario (mock_rl_loop + k8s-mockai + run.sh)
- scripts/ = shared infra setup/teardown, PoC orchestration in its own run.sh
2026-03-20 21:16:04 -07:00
yuqyang 43112c6535 docs: Phase 4a — per-module deploy layout, mock RL loop, algorithm stays Python
Decisions added:
- deploy/ organized by module (agl-lite, controller, mockai, agents, common)
- Algorithm script in Python on host (no Docker), uses AglLiteClient
- Mock RL loop: 2 iterations with weight update (deregister → 503 → re-register v2)
- Controller reuses agl-lite image (no separate Dockerfile)
- Version tracking verified in events across iterations
2026-03-20 21:06:54 -07:00
yuqyang 72d71712ad docs: condense completed phases 0-3 in todo.md to brief summary 2026-03-20 20:36:00 -07:00
yuqyang fc97f79f5d docs: update Phase 4a — decisions table, detailed task breakdown
Decisions: use mockai (~/mockai), client CLI, examples/ folder structure,
minikube image build, namespace nuke-and-recreate, separate deployments.

Tasks: kr8s adapter, client CLI, mockai deployment, example agents
(qa_agent + react_agent), algorithm script, K8s manifests, E2E tests.
2026-03-20 20:33:36 -07:00
yuqyang d2a0b57705 docs: split Phase 4 into 4a (mock, CPU) and 4b (real vLLM, GPU)
Phase 4a: E2E validation with mock OpenAI server inside minikube.
  - Mock server (configurable: normal, 503, crash)
  - Example agent + algorithm script
  - Minikube setup + kr8s adapter
  - Full lifecycle tests (batch rollouts, cancel, retry, weight update)

Phase 4b: E2E with real vLLM on 4x A6000 GPUs.
  - vLLM deployment, real inference, weight update protocol
  - Performance baseline (gateway overhead)
  - Prerequisite for Phase 5 (VERL integration)
2026-03-20 20:00:30 -07:00
Yuqing Yang 80f7014b54 add install minikube script 2026-03-20 11:11:44 +00:00
Yuqing 6711ac0cb0 add a minikube install for testing and user experience 2026-03-20 16:10:29 +08:00
Yuqing a4d03e7f65 refactor: single AGL_KEY in Secret for all auth env vars
K8s Secret stores only AGL_KEY. Job builder references it for all three:
  AGL_KEY → AGL_KEY (event posts, direct API)
  OPENAI_API_KEY → AGL_KEY (OpenAI SDK sends as Authorization: Bearer)
  ANTHROPIC_API_KEY → AGL_KEY (Anthropic SDK sends as x-api-key)

One secret, one value, three env vars. Gateway accepts both header formats.
2026-03-20 15:23:21 +08:00
Yuqing 697f4abebd fix: inject AGL_KEY into agent pods for gateway auth
Without AGL_KEY, agent event posts and LLM proxy requests get 401'd.
Sourced from same K8s Secret as OPENAI_API_KEY/ANTHROPIC_API_KEY.
2026-03-20 15:13:32 +08:00
Yuqing bbb230b35b docs: Phase 3 marked complete in todo.md (254 tests) 2026-03-20 14:58:01 +08:00
Yuqing 2dde63e6a6 feat: Phase 3 — Python client, K8s controller (job builder + reconciler + CLI)
Phase 3.0: AglLiteClient (agl_lite/client.py)
- Typed async HTTP client wrapping all agl-lite API endpoints
- Shared by controller and algorithm — no schema duplication
- 15 tests against real FastAPI app via httpx ASGITransport

Phase 3.1: ControllerSettings (agl_lite/controller/config.py)
- pydantic-settings with AGL_ env prefix
- namespace, poll_interval, max_queue_time, secret_name, etc.

Phase 3.2: Job spec builder (agl_lite/controller/job_builder.py)
- Pure function: (rollout, job_defaults, settings) → K8s Job manifest dict
- Env var injection: OPENAI_BASE_URL/KEY, ANTHROPIC_BASE_URL/KEY,
  AGL_TASK_INPUT, AGL_EVENT_URL, AGL_POD_UID (Downward API)
- timeout → activeDeadlineSeconds, max_retries → backoffLimit
- Mounts (hostPath, PVC, ConfigMap), overrides escape hatch
- 31 unit tests

Phase 3.3: Reconciler (agl_lite/controller/reconciler.py)
- K8sClient protocol for testability (mock in tests, kr8s in prod)
- Two concurrent loops: periodic_reconcile + watch_jobs
- Job creation failure → stay queuing, retry next cycle
- Max queue time → terminal_failed
- Crash recovery: orphaned running rollouts detected
- Cancel: queuing (no Job) and running (delete Job)
- Resources cache: persistent dict, no invalidation (immutable)
- 19 tests with fully mocked K8s + API clients

Phase 3.4: CLI (agl_lite/cli.py)
- agl-lite controller --agl-lite-url --namespace --secret-name
- Reads AGL_KEY from env, defers kr8s adapter to Phase 4

254 tests passing.
2026-03-20 14:57:34 +08:00
Yuqing 5ed39ef77d docs: update controller design + todo for Phase 3
- 1_k8s_controller.md: added failure handling (1.2), resources cache (1.3),
  module structure (2.1 client, 2.2 controller)
- todo.md: Phase 3 restructured into 3.0-3.5 (client, config, job builder,
  reconciler, CLI, tests)
2026-03-20 14:50:56 +08:00
Yuqing 02d489f682 docs: Phase 2 fully complete — all items checked off 2026-03-20 14:23:18 +08:00
Yuqing a36ccefeeb test: streaming proxy tests — SSE tee+buffer+capture verified
3 new tests:
- test_streaming_forward_and_capture: full flow (3 SSE chunks → tee to
  client → buffer → parse → model_request event with 3-item response list)
- test_streaming_route_rewrite: model_in → model_out works for stream=true
- test_streaming_empty_response: edge case (only [DONE], no data chunks)

189 tests passing.
2026-03-20 14:23:07 +08:00
Yuqing 37744274dc feat: warn when no gateway config loaded (passthrough mode) 2026-03-20 14:20:41 +08:00
Yuqing bf7b7df6ab docs: mark Phase 2 complete (except streaming test) 2026-03-20 14:17:09 +08:00
Yuqing 8b22b9152a feat: Phase 2.4-2.5 — gateway module (config, router, proxy)
Gateway module (agl_lite/gateway/):
- config.py: YAML route config (model_in → model_out + params.add/drop)
- router.py: GatewayRouter class — resolve route (exact match, passthrough),
  round-robin server selection, param adjustment (add/drop/rewrite model)
- proxy.py: HTTP forwarding via httpx (non-streaming + streaming with
  tee+buffer), model_request event capture (request + response + server meta)

Gateway routes wired in server/routes/gateway.py:
- LLM proxy: POST/GET /rollout/{rid}/attempt/{aid}/v1/{path}
- Event ingestion: POST /rollout/{rid}/attempt/{aid}/events

App lifespan creates GatewayRouter + shared httpx.AsyncClient.
Added pyyaml + pytest-httpx dependencies.
186 tests passing.
2026-03-20 14:16:46 +08:00
Yuqing 3ea8d246bc docs: sync todo.md — mark 2.1-2.3, 2.6 complete, update decisions 2026-03-20 14:00:30 +08:00
Yuqing 7f1de15c1b docs: clarify event routes — read-only API, writes via gateway 2026-03-20 13:59:17 +08:00
Yuqing 2b83bcaadd feat: warn when AGL_KEY not set (auth disabled) 2026-03-20 13:50:15 +08:00
Yuqing f7ffc45f4f refactor: batch store interface — enqueue_rollouts, register_models
Store owns batch semantics:
- enqueue_rollout → enqueue_rollouts(list[...]) → list[Rollout]
- register_model → register_models(list[...]) → list[ModelServer]
- Removed add_events (no batch event use case)

HTTP routes are now one-liners delegating to store.
156 tests passing.
2026-03-20 13:49:24 +08:00
Yuqing 3f4616aeb8 feat: Phase 2.1-2.3 — FastAPI app, auth, store API routes
Server layer:
- app.py: FastAPI lifespan, mounts all routes, wires store
- auth.py: AGL_KEY validation (Bearer + x-api-key), disabled when empty
- config.py: ServerSettings from env vars
- cli.py: 'agl-lite serve' entrypoint via typer + uvicorn

Store API routes (all async def, thin wrappers):
- rollouts: POST (batch), GET (query), GET/{rid} (detail+attempts), PATCH, POST/cancel
- events: GET with smart attempt resolution
- models: POST (batch register), GET (list), DELETE/{model}, DELETE (all)
- resources: POST, GET/latest, GET/{id}
- archive: POST /rollouts/archive

Gateway routes (stub):
- POST /rollout/{rid}/attempt/{aid}/events — agent event ingestion (working)
- POST /rollout/{rid}/attempt/{aid}/v1/{path} — LLM proxy (501 placeholder)

157 tests passing (124 store/schema + 33 server).
2026-03-20 11:56:33 +08:00
Yuqing dd4495a62d chore: mark #11 streaming deferred to impl 2026-03-20 11:52:41 +08:00
Yuqing ab3d6f5d8c docs: gateway as peer module to store, update project layout and todo
Gateway logic extracted to agl_lite/gateway/ (config, router, proxy)
peering with agl_lite/store/. Server is thin HTTP wrapper over both.

- dev_guidelines.md: updated project layout, removed ForbiddenError
- todo.md: Phase 2 tasks restructured (2.4 gateway module, 2.5 gateway
  routes, 2.7 integration tests), resolved open question #10
- Event ingestion: gateway-side POST /rollout/{rid}/attempt/{aid}/events
2026-03-20 11:51:03 +08:00
Yuqing 2d31b235d6 docs: model routing redesign — model_in→model_out, per-route params, online RL
Architecture doc updated:
- Gateway routing: model_in → model_out mapping via static YAML config
- Parameter adjustment is per-route (add/drop fields)
- model_request event includes server metadata (model, endpoint, version)
- Weight update protocol covers sync RL and online RL (rolling update)
- Store interface updated to new model methods

todo.md: resolved open questions #9 (gateway config) and #12b (batch-only APIs).
124 tests passing.
2026-03-20 11:16:43 +08:00
Yuqing 28699ca231 redesign model server: model as grouping key, nested store, batch-only API
ModelServer gets 'model' field — grouping key for gateway routing.
Store: Dict[model, Dict[endpoint, ModelServer]] (nested dict).
API body: flat list of {model, endpoint, version, token?} (easy validation).

New store methods:
- register_model(model, endpoint, version, token) — upsert by (model, endpoint)
- get_model_pool(model) — all servers for a model
- remove_model_servers(model, endpoints?) — remove specific or all
- remove_all_models() — clear everything

Supports online RL rolling updates: individual servers can have
different versions within the same model pool.

DELETE /api/models/{model} accepts optional body with endpoints list.
Empty pool auto-deleted.

Also:
- EnqueueBatchRequest simplified (no shared config — client-side sugar)
- RegisterModelRequest gets 'model' field
- Added DeleteModelServersRequest schema
- 124 tests passing
2026-03-20 11:14:53 +08:00
Yuqing aef7277702 simplify auth: single AGL_KEY, add optional token on ModelServer
MVP auth: one shared key (AGL_KEY) for all components. No roles,
no path-based permissions. 401 if key doesn't match, pass if it does.

ModelServer gets optional 'token' field — gateway forwards it as
Authorization: Bearer when proxying to model servers that need auth.
Unauthenticated model servers (cluster-internal vLLM) leave it null.

Updated: architecture doc, get_started.md, todo.md frozen decisions.
2026-03-19 23:52:58 +08:00
Yuqing 90e3004919 chore: todo.md — Phase 2 decisions and open questions tables 2026-03-19 23:39:23 +08:00
Yuqing bc7db177b5 chore: sync todo.md — update test counts, remove stale optimistic locking refs 2026-03-19 23:22:55 +08:00
Yuqing 95d2bc188f refactor: PATCH semantics for update_rollout — drop expected_version
UpdateRolloutRequest → PatchRolloutRequest:
- All fields optional (true partial update)
- Uses model_dump(exclude_unset=True) to apply only sent fields
- Absent field = untouched, explicit null = clear field
- No expected_version — no GET-before-PATCH round trip needed

Controller can now just send:
  PATCH /api/rollouts/{rid} {"status": "succeeded", "succeeded_attempt_id": "pod-uid"}

State transition validation still enforced server-side.
Version field kept on Rollout (informational, bumped internally).

Updated architecture doc, controller pseudocode, todo.md.
New tests: empty_patch_is_noop, update_without_status, explicit_null_clears_field.
114 tests passing, ruff clean.
2026-03-19 23:21:44 +08:00
Yuqing 894e17b58f chore: clean up todo.md — mark Phase 0+1 completed, remove done items 2026-03-19 22:47:13 +08:00
Yuqing 1429ebec36 refactor: store methods accept request objects, not decomposed args
enqueue_rollout(req: EnqueueRolloutRequest) instead of (input, config, resources_id)
update_rollout(rollout_id, req: UpdateRolloutRequest) instead of (rollout_id, status, expected_version, ...)

Eliminates pointless field-by-field unpacking between HTTP layer and store.
Tests updated with _enqueue/_update helpers. 110 tests passing.
2026-03-19 22:20:16 +08:00
Yuqing 1d5ce35a1f refactor: drop model_id — use endpoint as natural key
ModelServer keyed by endpoint URL, not auto-generated UUID.
Algorithm registers/removes servers by the URL it already knows.

Changes:
- ModelServer schema: removed model_id field
- Store: models dict keyed by endpoint, upsert on re-register
- API: DELETE /api/models?endpoint=<url> replaces DELETE /api/models/{model_id}
- POST /api/models has upsert semantics (same endpoint → update version)
- Updated architecture doc, todo.md, tests
- New test: test_upsert_same_endpoint
- 109 tests passing, ruff clean
2026-03-19 22:07:15 +08:00
Yuqing e8ee4f19f7 feat: Phase 1 — In-Memory Store implementation
InMemoryStore (agl_lite/store/memory.py):
- All methods are plain def (synchronous) — no async, no locks
- Data: dict[rid, Rollout], dict[rid, dict[aid, list[Event]]],
  dict[res_id, ResourcesUpdate], dict[model_id, ModelServer]

Rollout operations:
- enqueue_rollout: creates in QUEUING with UUID
- update_rollout: state transition validation + optimistic locking
- cancel_rollout: sets cancel_requested flag, idempotent, rejects terminal
- query_rollouts: filter by ids, status_in, cancel_requested, pagination
- get_rollout, rollout_exists: lookup helpers

Event operations:
- add_event/add_events: append to nested dict (rid → aid → list)
- query_events: smart attempt_id resolution (succeeded → latest → empty)
- list_attempts: attempt IDs ordered by first event timestamp

Resource operations:
- add_resources: immutable snapshots with JobDefaults validation
- get_resources, get_latest_resources

Model server operations:
- register_model/register_models, list_models, remove_model, remove_all_models

Archive:
- archive_rollouts: validate terminal, write JSONL (append), purge from store
- JSONL format: one line per rollout with events + resources

Tests: 108 passing (36 schema + 72 store), ruff clean.
2026-03-19 21:48:41 +08:00
Yuqing dfb7a6691a docs: restore kr8s async, clarify sync scope is in-memory store only 2026-03-19 21:36:23 +08:00
Yuqing 358f6af60e docs: add concurrency model to dev_guidelines.md
- Store methods are plain def (sync) — no async, no I/O
- Route handlers must be async def — never sync def (thread pool breaks safety)
- Single worker, single event loop, plain dict/list with no locks
- Document why this works for performance (I/O-bound hot path)
- Update kr8s description, testing section for sync store
2026-03-19 21:24:51 +08:00
Yuqing 0b47b57a73 docs: mark Phase 0 completed items in todo.md 2026-03-19 20:47:23 +08:00
Yuqing eed4853804 docs: sync architecture, controller, and todo with schema changes
- Remove event_id from Event schema in architecture doc (events
  identified by position, not ID)
- Remove standalone GET /api/attempts/{rid} endpoint — fold attempt
  listing into GET /api/rollouts/{rid} response
- Update Store API: list_attempts returns List[str] from nested dict
- Document nested dict storage: rid → aid → list[Event]
- Add overrides escape hatch to JobDefaults description and merge diagram
- Update todo.md: event storage is per-(rid, aid), no event_id,
  JobDefaults has overrides field
- Fix 1_k8s_controller.md: reference rollouts endpoint for attempt lookup
- Update endpoint count from ~19 to ~18
2026-03-19 20:17:59 +08:00
Yuqing 191c3459c3 refactor: schema refinements from review
(a) Removed event_id from Event — events identified by position in
    ordered list, not by separate ID. No API references events by ID.
(b) JobDefaults: extra='forbid' → added 'overrides: dict' escape hatch.
    Known fields validated, unknown K8s fields (labels, annotations,
    dnsPolicy, etc.) go into overrides and are merged raw by controller.
(c) Removed AttemptInfo — the /api/attempts endpoint is a debugging
    convenience, not needed for MVP. Smart attempt_id resolution
    handles the common case.
2026-03-19 20:07:15 +08:00
Yuqing 808694991f feat: Phase 0 — project skeleton and frozen schemas
Project setup:
- pyproject.toml (uv, hatchling, fastapi, pydantic v2, httpx, etc.)
- Package layout: agl_lite/{schemas,store,server,controller}
- Dev tooling: ruff, pyright, pytest-asyncio
- uv.lock committed for reproducibility

Frozen schemas (agl_lite/schemas/):
- rollout.py: Rollout, RolloutStatus, RolloutConfig, Mount,
  VALID_TRANSITIONS, TERMINAL_STATUSES
- event.py: Event, ModelRequestData, RewardData, AttemptInfo
- resources.py: ResourcesUpdate, JobDefaults (extra=forbid),
  K8sResources — validates job_defaults at POST time
- model_server.py: ModelServer
- errors.py: NotFoundError, ConflictError, InvalidTransitionError
- api.py: all request/response body models

Tests: 36 passing, ruff clean, all schemas validated.
2026-03-19 20:01:48 +08:00
Yuqing dfc8a1346b docs: add dev guidelines, rename refactor/ → design/
- docs/dev_guidelines.md: tooling (uv, ruff, pyright, pytest, structlog),
  project layout, code conventions, git conventions, testing strategy
- docs/refactor/ → docs/design/ — these are design docs, not refactoring notes
- Updated all cross-references in dev/issues/README.md and dev/todo.md
2026-03-19 19:55:11 +08:00
Yuqing 9622589746 docs: multi-SDK support — OpenAI + Anthropic env vars and auth
Gateway proxies both /v1/chat/completions (OpenAI) and /v1/messages
(Anthropic/Claude). Controller injects both SDK env var pairs:
- OPENAI_BASE_URL + OPENAI_API_KEY (Authorization: Bearer)
- ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY (x-api-key header)

Both point to same gateway URL, same agent key. Gateway auth
checks both header formats. Agent uses whichever SDK it wants —
zero modification needed.
2026-03-19 17:06:59 +08:00
Yuqing dcf3c7c9d0 docs: clarify archive append semantics and secret injection mechanism
(a) Archive: user-specified .jsonl path, append if exists, create if not.
    Multiple archive calls build one growing file per training run.
(b) Agent auth: OPENAI_API_KEY (env var name) + secretKeyRef (injection
    mechanism) — same thing, not conflicting. Controller only writes
    the reference, K8s resolves the actual value at pod creation.
2026-03-19 17:00:28 +08:00
Yuqing ae6e2f3672 docs: rewrite implementation plan (aligned with final architecture)
Replaces the stale phase-based TODO with implementation-ready plan:
- Phase 0: Schemas + project skeleton (freeze contracts)
- Phase 1: In-memory store (all operations, tested without HTTP)
- Phase 2: HTTP API + gateway + auth (agl-lite serve)
- Phase 3: K8s controller (reconcile, Job rendering)
- Phase 4: E2E validation on minikube
- Phase 5: VERL integration
- Phase 6: Polish

Includes pre-implementation decisions table (frozen, not revisitable).
Key decisions: typed JobDefaults, secretKeyRef injection, JSONL archive,
round-robin routing, single namespace, activeDeadlineSeconds for timeout.
2026-03-19 16:50:49 +08:00
Yuqing 0cedac72d7 docs: agent contract is 4 env vars, algorithm key always used
- Agent contract: OPENAI_BASE_URL, OPENAI_API_KEY, AGL_TASK_INPUT,
  AGL_EVENT_URL (was 3, now 4 with auth key)
- Algorithm key: always used by algorithm for auth. Optionally stored
  in K8s Secret — algorithm may run outside cluster and get key from
  its own config.
- Added Authorization headers to all algorithm Python examples in
  get_started.md
2026-03-19 14:50:22 +08:00
Yuqing 9d42a6b8b0 docs: add getting started guide (setup flow, 7 steps)
Steps 1-4: one-time infra (K8s, RBAC, Secrets, agl-lite, controller)
Steps 5-7: per-experiment (resources, model servers, rollouts)

Includes YAML manifests, Python examples, and what-happens-next flow.
Algorithm key omitted for MVP — trusted network assumption.
2026-03-19 14:47:48 +08:00
Yuqing a748ed74ba docs: API key authentication and role-based access
Resolves issue 011 (cross-boundary auth and transport security).

MVP: 3 API keys (agent, controller, algorithm) in K8s Secret.
- Agent key via OPENAI_API_KEY env var — OpenAI SDKs send it as
  Authorization header automatically. Zero agent modification.
- Controller key for rollout status updates.
- Algorithm key for full access.

Role-based access enforced per endpoint. agl-lite Service has zero
K8s dependency — pure HTTP server, keys from Secret mount or env vars.
Only the K8s Controller needs K8s API access (Jobs, Secrets, ConfigMaps).

Production path: per-rollout JWTs, mTLS, TLS at ingress.
2026-03-19 14:40:11 +08:00
Yuqing d1a1864a90 docs: fix stale link for issue 010 2026-03-19 14:33:21 +08:00
Yuqing 93cdde60d7 docs: validate rollout_id on hot path, implies in-process store
Reversed the 'no validation' decision from issue 009. With in-process
store, rollout_id validation is a dict lookup (~100ns) — free compared
to LLM inference (seconds). Benefits:
- 404 rejects orphan requests early (no wasted GPU inference)
- 404 rejects orphan events early (no garbage in store)
- Zero cost: synchronous, no await, no race conditions

This reinforces the unified service design: gateway + store must be
in the same process for the validation to be free. A separate store
would turn a 100ns dict lookup into a 1-50ms network round-trip.
2026-03-19 14:30:21 +08:00
Yuqing 83c053794a docs: no validation on event ingestion (hot path fast)
Resolves issue 009. Event writes are fire-and-forget — no rollout_id
or attempt_id existence check. Orphan events are harmless (never
queried) and cleaned by archive or periodic GC.
2026-03-19 14:24:16 +08:00
Yuqing ddaa7f8d9a docs: fix naming to resources_id (matches original Agent Lightning) 2026-03-19 14:20:43 +08:00
Yuqing 4900f4b924 docs: revert to singular resource_id (one snapshot bundles all)
Original Agent Lightning uses NamedResources = Dict[str, Resource] —
one snapshot packs everything (LLMs, prompts, config) into a single
bundle with one ID. Our resource_ids: List[str] was over-engineered.

Back to: resource_id: Optional[str] — one snapshot containing
job_defaults + prompts + eval config + whatever else. Simple,
matches the original pattern.
2026-03-19 14:19:19 +08:00
Yuqing 94f01bd6ab docs: fix resource caching description — deduplication, not caching
Resources are immutable snapshots with unique IDs. Controller
deduplicates fetches within a reconcile cycle (batch shares same
resource_ids → each fetched once). No invalidation needed — IDs
are immutable. New job defaults = new snapshot = new ID.
2026-03-19 14:17:08 +08:00
Yuqing 8b47960b14 docs: rollout config refinements (a-d)
(a) resources_id → resource_ids: List[str] — rollout can depend on
    multiple resource snapshots (job_defaults, prompts, eval config)
(b) job_defaults is a reserved resource key — documented in a table.
    All other keys are user-defined and opaque to agl-lite.
(c) Removed 'mode' field from RolloutConfig — just use
    environment_variables: {MODE: 'train'} if the agent needs it
(d) env → environment_variables — avoids confusion with RL
    'environment' concept
2026-03-19 14:13:48 +08:00
Yuqing 73672f1bc2 docs: job defaults via Store resources (not config file)
Controller reads job_defaults from the rollout's resources_id snapshot,
not from a static config file. This means:
- No kubectl needed — algorithm/setup script POSTs to /api/resources
- Controller stays task-agnostic (no baked-in config)
- Immutable snapshots: same resources_id always returns same defaults
- Cache-friendly: entire batch shares one resources_id → one fetch
- Restored resources_id field on Rollout record

Workflow:
1. POST /api/resources {job_defaults: {resources, node_selector, ...}}
2. POST /api/rollouts {resources_id: ..., config: {image, command, ...}}
3. Controller merges: resources.job_defaults ← rollout.config → Job spec
2026-03-19 13:59:45 +08:00
Yuqing bdd92de828 docs: two-layer rollout config (infra defaults + algorithm overrides)
Resolves issue 007 (rollout config schema).

Algorithm-facing RolloutConfig (5 fields, no K8s knowledge needed):
- image, command, env, mount (describe the container)
- timeout, max_retries, mode (execution policy, optional)

Infra-level job_defaults.yaml (loaded by controller at startup):
- resources, node_selector, tolerations, service_account, etc.
- Set by DevOps, algorithm never sees these

Controller merges both layers into K8s Job spec.
Algorithm overrides take precedence where specified.

Batch enqueue supports shared config:
POST /api/rollouts {config: {...}, rollouts: [{input: ...}, ...]}

Also: removed resources_id from Rollout (resources endpoint is
separate; config.mount handles data dependencies).
2026-03-19 13:39:52 +08:00
Yuqing e1bbe5e3ba docs: task input delivery via AGL_TASK_INPUT env var
Resolves issue 002 (task input delivery to agent).

Controller injects AGL_TASK_INPUT env var with JSON-serialized
rollout.input into the Job template. Agent reads it like any env var.

Three env vars define the full agent contract:
- OPENAI_BASE_URL: LLM calls (changes per attempt, includes rid+aid)
- AGL_TASK_INPUT: task payload (same across retries)
- AGL_EVENT_URL: optional event posting (rewards, custom events)

Zero agent modification needed. Every language reads env vars.
Size limit ~1MB (K8s Pod spec / etcd). Fine for text prompts.
2026-03-19 11:53:25 +08:00
Yuqing d43d3b4b38 docs: mark case A (colocated) as MVP deployment, case B as future 2026-03-19 10:44:21 +08:00
Yuqing 62d401b7ad docs: bulk data transfer analysis, close batch query issue
Resolves issue 004 (batch trajectory query for training).

No batch endpoint needed:
- Concurrent GET /api/events via asyncio.gather() is fast enough
- One fewer endpoint to implement, test, document
- Partial failure handled naturally per-request

Data transfer analysis for two deployment cases:
- Colocated (case A): loopback, not a bottleneck even at 2.5GB.
  Shared memory unnecessary — breaks API boundary for marginal gain.
  Msgpack/protobuf available if serialization matters.
- Remote (case B): gzip (5-10x compression) + archive-to-shared-storage
  pattern. Archive feature serves dual purpose: data lifecycle + bulk
  export. No new API needed.
2026-03-19 10:43:19 +08:00
Yuqing 46c46356b0 docs: add method column to path layout table for bird-eye view
Also expanded to show /api/models/{model_id} and /api/resources/{id}
as separate rows for completeness. 12 path patterns, 19 method+path
combinations across 6 domains.
2026-03-19 10:30:28 +08:00
Yuqing 9a4326d7cb docs: algorithm-driven data lifecycle (archive and purge)
Resolves issue 008 (data retention and eviction).

POST /api/rollouts/archive:
- Algorithm decides when to drop consumed data (not the system)
- Critical path: query trajectories → train → archive batch
- Optional backend for persistence (JSONL for MVP, pluggable)
- No backend = just discard from hot store
- Rejects non-terminal rollouts (400)
- One JSONL line per rollout: {rollout, events} — self-contained

Replaces original's automatic byte-threshold eviction which risked
deleting unconsumed data. Explicit > implicit for data lifecycle.
2026-03-19 10:24:38 +08:00
Yuqing 7fbca400e5 docs: gateway parameter adjustment (add_params, drop_params)
Static config loaded at gateway startup, not changeable at runtime.
Normalizes requests for backends that don't support all OpenAI params
(vLLM, TGI) and enforces training-time sampling parameters.

Event records both original request and adjusted params diff,
so trajectory captures agent intent and actual model input.

Also added 'status' field to model_request event payload
(ok, client_disconnected, stream_error) for streaming edge cases.
2026-03-19 10:09:27 +08:00
Yuqing 1a5ef5c1dd docs: drop wait_for_rollouts, add batch ID support to GET /api/rollouts
wait_for_rollouts was server-side long-poll that just hid a polling loop.
Replaced by client-side polling with GET /api/rollouts?ids=r1,r2,r3.
Simpler: no asyncio.Condition per rollout, no connection timeout
handling, no notification machinery.

GET /api/rollouts now accepts 'ids' param for batch fetch.
POST /api/rollouts also supports batch (single object or array).
Endpoint count: 19 → 18.
2026-03-19 09:53:02 +08:00
Yuqing e0216ffec4 docs: drop explicit sequence field, add concurrency analysis
Event ordering:
- Removed 'sequence' field from Event schema. Ordering is an emergent
  property of the storage backend: list index (in-memory), ROWID
  (SQLite), SERIAL (PostgreSQL).
- Single-threaded asyncio event loop guarantees temporal insertion
  order. No locks needed on the hot path.
- API returns events in insertion order; consumers use array position.

Concurrency analysis (resolves issue 005):
- Hot path is I/O-bound (seconds waiting for LLM inference).
- Event store naturally partitioned by (rollout_id, attempt_id) —
  different agents never contend on the same data.
- No locks needed: model server registry is read-heavy/write-rare,
  event writes are per-partition, rollout updates use optimistic locking.
- Single Python async instance handles 5,000+ concurrent connections
  comfortably. Most RL setups use 64-512 agents.
- Bottleneck is LLM inference servers, never the gateway.
- Scaling path: stateless instances + DB-assigned ordering (future).

Also resolved issue 006 (concurrent calls note + adapter relabeling).
2026-03-19 00:07:53 +08:00
Yuqing b11caf61a6 docs: streaming proxy design and concurrent request semantics
Resolves issue 001 (streaming) and 006 (concurrent calls + adapter).

Streaming proxy (tee approach):
- Gateway tees SSE stream: chunks forwarded to agent immediately,
  buffered in memory for event capture
- Event written at stream completion with full assembled response
- Edge cases: client disconnect (capture continues), backend error
  (partial event with status field), memory bounded (~50MB for 100
  concurrent streams)
- Replaces original StreamConversionMiddleware which forced stream=false
  to backend (lost real streaming, was an OTEL workaround)

Concurrent request semantics:
- sequence assigned at event write time (stream completion)
- Concurrent requests get arbitrary sequence order — this is storage
  ordering, not causal ordering
- timestamp provides approximate causal information

Adapter relabeled as example (episode-level reward). Users implement
their own for per-step, discounted, advantage-based, etc.
2026-03-18 23:44:13 +08:00
Yuqing 1339372556 docs: batch model server registration, 503 always-on rationale
POST /api/models accepts single object or array for batch registration.
503 is always returned when no servers available — no on/off switch
needed. Sync RL never hits the window; async RL benefits from retry.
Aborting agents is what cancel_rollout is for.
2026-03-18 23:37:35 +08:00
Yuqing e3b3f650e0 docs: first-class model server management and async RL support
Resolves issue 003 (LLM backend routing and resource mapping).

Model server registry (/api/models):
- CRUD API for inference servers with version tracking (training step)
- ModelServer record: {model_id, endpoint, version, created_at}
- Gateway routes to registered servers; returns 503 when none available

Weight update protocol:
- DELETE /api/models → gateway returns 503 + Retry-After
- OpenAI SDKs auto-retry on 503 — agent pod stays alive, no K8s retry
- POST /api/models with new version → routing resumes
- Emergent from CRUD, no special 'update mode' API

Async RL support:
- model_version recorded per model_request event
- Enables importance sampling, off-policy correction, data filtering
- Turn-level async RL: single trajectory spans multiple policy versions

Also updated:
- model_request event payload (added model_version field)
- Resources narrowed to prompts/config (model endpoints in /api/models)
- Component description, path layout, comparison tables
2026-03-18 23:22:23 +08:00
Yuqing 790f122ee7 docs: fix line breaks in 1_k8s_controller.md, remove resolved issue file 2026-03-18 23:00:23 +08:00
Yuqing 2fb2fa96d2 docs: resolve issue 010 — find_succeeded_pod_uid
Created docs/refactor/1_k8s_controller.md for controller implementation
details. Pod UID resolved via label query in watch callback, GC race
mitigated by ttlSecondsAfterFinished.
2026-03-18 22:56:28 +08:00
Yuqing 56a8ff1114 docs: add issue 011 — cross-boundary authentication and transport security
Covers: TLS for cross-boundary traffic, role-based access (agent,
controller, algorithm), API key for MVP, scoped JWT tokens for
production, threat model by deployment scenario.
2026-03-18 20:06:50 +08:00
Yuqing c8e8a8acea docs: log 10 architecture review issues in dev/issues/
Critical review of 0_architecture.md from the perspective of a senior
system architect with RL system and LLM infrastructure expertise.

🔴 Architectural gaps:
  001 - Streaming LLM response handling (tee stream approach)
  002 - Task input delivery to agent (env var vs API fetch)
  003 - LLM backend routing and resource mapping
  004 - Batch trajectory query for training (N+1 problem)

🟡 Important design issues:
  005 - Gateway scaling and sequence counters
  006 - Concurrent LLM calls and adapter design
  007 - Rollout config schema
  008 - Data retention and eviction

🟢 Minor/deferrable:
  009 - Event ingestion validation
  010 - find_succeeded_pod_uid implementation

Each issue includes: problem statement, analysis of options,
recommendation, and list of doc sections to update.
2026-03-18 20:03:54 +08:00
Yuqing 087a8fdd65 docs: smart attempt_id default for event queries
GET /api/events attempt_id resolution when omitted:
1. succeeded_attempt_id if rollout succeeded
2. Most recently created attempt (MAX of MIN(timestamp) from events)
3. Empty result if no events exist

Attempts are derived from the events table via GROUP BY — no separate
attempt storage. list_attempts returns {attempt_id, first_seen,
last_seen, event_count} ordered by first_seen.
2026-03-18 19:41:23 +08:00
Yuqing c6137b73a1 docs: merge /api/trajectories into /api/events query params
Trajectory is just GET /api/events?rollout_id={rid}&attempt_id={aid}.
No need for a separate endpoint. Updated path layout, Store API
pseudocode, and comparison tables.
2026-03-18 19:26:46 +08:00
Yuqing 21c071bb7e docs: unify Gateway+Store into single service, add API comparison
Section 3.1/3.2: Gateway and Store merged into 'agl-lite Service' —
one HTTP endpoint, one deployment. Agent pods, K8s controller, and
Algorithm all talk to the same service URL.

Section 3.3: Updated env var examples to use AGL_LITE_URL as single
base, with OPENAI_BASE_URL and AGL_EVENT_URL derived from it.

Section 3.4 (rewritten as 'Unified API Spec'):
- Path layout table showing all ~15 endpoints
- LLM proxy paths: /rollout/{rid}/attempt/{aid}/v1/... and /events
- Store paths: /api/rollouts, /api/events, /api/trajectories,
  /api/attempts, /api/resources with full method/path/description tables

Section 3.5: Updated K8s resource table for unified service.

Section 4 (new): API Change Summary
- 4.1: Original Agent Lightning API surface (25+ methods, 6 domains,
  separate LLM Proxy with 5 middleware/exporters)
- 4.2: agl-lite API surface (~15 endpoints, 4 domains, one service)
- 4.3: What's removed and why (18 items with rationale)
- 4.4: What's new (7 items with rationale)
2026-03-18 17:52:52 +08:00
Yuqing 8ed4cd3c7a docs: comprehensive K8s controller design with cancel support
Section 3.3:
- Added Rollout record with RolloutStatus enum (queuing, running,
  succeeded, terminal_failed, cancelled)
- cancel_requested as separate flag (user intent) vs status (controller action)
- Full state machine diagram with valid transitions table
- Store-enforced invariants (final states, no backwards transitions)

Section 3.3 Store API:
- update_rollout with expected_version for optimistic locking
- cancel_rollout sets cancel_requested flag
- query_rollouts supports cancel_requested filter
- wait_for_rollouts with long-polling semantics
- Removed dequeue_rollout (controller polls Store directly)

Section 3.5 (rewritten as 'K8s Controller'):
- Deterministic Job naming (agl-rollout-{rollout_id}) for idempotency
- Job template with ttlSecondsAfterFinished for auto-cleanup
- Controller main loop: watch + poll + periodic full reconcile
- Full reconcile logic in Python pseudocode:
  - handle_cancel: checks Job Complete (success wins), Job Failed
    (mark cancelled), Job active (delete with Foreground propagation,
    wait for cleanup before marking cancelled)
  - handle_queuing: create Job, handle AlreadyExists, handle creation failure
  - handle_running: sync Job conditions to Store status
- All updates use optimistic locking with ConflictError retry
- Comprehensive edge cases: controller crash recovery, leader election
  race, Store unavailable, Job creation race, external Job deletion,
  cancel+success race, cancel+failure race, cancel during termination,
  node partition, stale queries, controller downtime
2026-03-18 17:42:47 +08:00
Yuqing 14c1f9d5a2 docs: event-based trajectory model — two reserved types, rest is open
Trajectory is now a sequence of Event objects. Only two event types have
well-known structure that agl-lite understands:
- model_request: auto-captured by Gateway (request + response + latency)
- reward: scalar value + optional message, reported by runner/environment

All other event types (tool_result, observation, action, custom, etc.)
are opaque dicts with a 'type' field — stored and delivered as-is.
Users define their own types and consume them in their own algorithms.

Updated sections: 2.1, 2.2, 3.3 (Event/Trajectory model, Store API),
3.4 (Gateway event endpoint), 3.5 (Job template with AGL_EVENT_URL),
3.6 (Adapter filters by event type).
2026-03-18 17:20:15 +08:00
Yuqing b9a2decc76 docs: design attempt_id from K8s pod UID, path-based gateway routing
Section 3.3:
- Added 'ID Generation and Flow' table (rollout_id from Store, attempt_id from K8s pod UID)
- K8s Job template showing Downward API + env var composition for OPENAI_BASE_URL
- 'Attempt as a data tag, not an entity' — explicit that attempt has no lifecycle in Store
- Retry data isolation example showing clean partitioning by pod UID
- Added list_attempts() to Store API
- Annotated RequestRecord/Trajectory with pod UID comments

Section 3.4:
- Replaced routing options with concrete request flow walkthrough
- Path-based routing is the chosen approach (not header-based)
- Gateway auto-increments sequence per (rollout_id, attempt_id)

Section 3.5:
- Added full Job template YAML example with Downward API
2026-03-18 17:03:48 +08:00
Yuqing d27508ca98 docs: agents are language-agnostic containers, remove Section 4
- Agent is no longer a Python base class; it's any LLM program that
  reads Gateway endpoint from env vars (OPENAI_BASE_URL, etc.)
- Can be written in any language/framework, packaged as container
- No SDK or base class required — only contract is HTTP API consumption
- Removed LitAgent from 'What Stays', added to 'What Gets Removed'
- Updated Gateway diagram to show 'Agent (any language)'
- Updated dev/todo.md Phase 3 accordingly
- Section 4 (Refactoring Phases) was already absent from arch doc
2026-03-18 16:22:27 +08:00
Yuqing 2abdac190d docs: clarify deployment flexibility — no co-location assumptions
- Compute Backend is a user-managed prerequisite (same cluster, separate
  cluster, or remote service)
- AGL-Lite (Store + Gateway) can be co-located with runner or compute
  backend; only needs to expose HTTP API
- Agent Runner only needs network access to Store + Gateway endpoints
- Updated architecture doc sections 3.1, 3.2, 3.5, Store API note
- Updated todo.md Phase 5 and Phase 1 descriptions
2026-03-18 16:03:38 +08:00
Yuqing 5e7221e736 docs: integrate excalidraw architecture diagram into 0_architecture.md
Replace ASCII art with reference to docs/images/lite_arch.excalidraw.svg
showing the three-column layout: Compute Backend (green), AGL-Lite (blue),
Agent Runner/K8s (red).
2026-03-18 15:39:56 +08:00
Yuqing bd565aaa90 docs: add architecture analysis and refactoring plan
- docs/refactor/0_architecture.md: comprehensive mapping of Agent Lightning
  architecture to agl-lite simplifications (core loop, data types, store,
  LLM proxy, execution strategies, VERL integration)
- dev/todo.md: phased refactoring plan (Phase 0-7) with task breakdown
- Covers all 4 key changes: replace litellm, remove OTEL, simplify
  trajectory format, adopt K8s runner
2026-03-18 14:34:13 +08:00
Yuqing 11bdda2b0d Initial commit 2026-03-18 13:23:01 +08:00
Leonardo Pinheiro c746af2f76 vercel ai webshop example (#440) 2026-02-11 22:20:42 +08:00
Imran Siddique 49bf9cd9ec [Contrib] Agent-OS Integration: Kernel-Level Safety for RL Training (#478)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-11 09:00:36 +08:00
Jeonghye Kim 9864b8fbff New example: AGL Simulation (#367) 2026-02-10 12:00:12 +08:00
Yuge Zhang 82d8535048 CI maintenance (Feb) (#479) 2026-02-09 15:59:02 +08:00
Dunura Saradha Witharama 5fa6582491 Validate input length in generate_id utility (#460) 2026-02-09 14:39:50 +08:00
Salman Chishti 3f36754d64 Upgrade GitHub Actions for Node 24 compatibility (#465)
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-01-27 15:16:38 +08:00
Barkhayot Juraev b0592efe1f [chore] fix minor typos (#457)
Co-authored-by: Barkhayot Juraev <barkhayotjuraev@Barkhayots-MacBook-Pro.local>
2026-01-27 14:57:37 +08:00
Yuge Zhang 5f3093d62a Pipeline maintainence (#451) 2026-01-19 12:10:23 +08:00
荔枝 bfb94a8750 Make APO templates configurable via constructor arguments (#443) 2026-01-12 16:20:40 +08:00
Yuge Zhang 25eda47a29 Fix broken links in changelog (#433) 2025-12-24 19:13:34 +08:00
Yuge Zhang a214474402 Bump to 0.3.1 (#431) 2025-12-24 11:45:50 +08:00
Yuge Zhang 3b5d733861 [Release] v0.3.0 (#427)
Deploy Documentation / deploy (push) Has been cancelled
PyPI Release / check-version (push) Has been cancelled
PyPI Release / publish-pypi (push) Has been cancelled
2025-12-24 09:46:58 +08:00
Yuge Zhang 158f5df28e Fix documentation and dashboard building issues (#429) 2025-12-23 23:55:59 +08:00
Yuge Zhang 40dc59205b Scale out benchmark parameters (#428) 2025-12-23 23:32:19 +08:00
Yuge Zhang c1a43b6c3a Update parallelization guides (#426) 2025-12-23 14:57:24 +08:00
665 changed files with 31860 additions and 145623 deletions
+199
View File
@@ -0,0 +1,199 @@
---
name: release
description: Prepare and publish stable Agent Lightning releases through the repository's version bump, pull-request checks, merge, tag, PyPI trusted-publishing, and versioned-documentation workflows. Use when asked to plan, cut, verify, or explain a release; treat nightly TestPyPI builds as a separate path.
---
# Release Agent Lightning
Merging a pull request does not publish a stable release. Stable publication is
triggered only by pushing a `v*` tag to the canonical repository; the tagged
commit is what gets tested, built, and uploaded. That same tag push also deploys
versioned documentation and moves the public `stable` alias, so a release has two
public side effects, not one.
## Establish the release state
1. Confirm the repository root, clean working tree, current branch, and remotes.
2. Resolve the canonical `OWNER/REPO` and its default branch with `gh repo view`.
Identify the local remotes for that repository and the contributor fork by
their URLs; do not assume particular remote names.
3. Inspect the release contract in:
- `.github/workflows/pypi-release.yml`
- `.github/workflows/docs.yml`
- `.github/workflows/tests.yml`
- `scripts/bump_version.sh`
- `pyproject.toml`
- `agentlightning/__init__.py`
4. Confirm the canonical default branch is already green before branching from
it. A release branch inherits every failure that main is carrying.
5. Query the canonical repository's tags and compare them with the versions
published at `https://pypi.org/pypi/agentlightning/json`. Confirm the target
version exists in neither place, and stop for an explicit release decision
when either of these holds:
- A tag exists with no matching PyPI version. A published version is
immutable, and its tag must never be reused or moved. A tag that never
published is a different situation and still needs a human decision,
informed by why it did not publish. See "Recovering a tag that never
published" below.
- The proposed bump would skip a version that was tagged but never published.
6. Treat verified PyPI trusted-publisher configuration for the canonical
repository and `pypi-release.yml` as a prerequisite. If it cannot be
inspected directly, require confirmation from an authorized PyPI project
owner before pushing the release tag.
## Prepare and merge the version pull request
Start a release branch from a freshly fetched canonical default branch, not
from another feature branch. The branch name is only a recommendation:
```bash
git fetch <canonical-remote> <default-branch>
git switch -c chore/release-vX.Y.Z <canonical-remote>/<default-branch>
scripts/bump_version.sh patch # or minor / major
```
The bump rewrites exactly three files. Confirm that with `git diff --stat`:
- `pyproject.toml`
- the `agentlightning` entry in `uv.lock`
- `agentlightning.__version__` in `agentlightning/__init__.py`
Other version strings in the tree, such as the FastAPI `version` in
`agentlightning/server/app.py`, are deliberately outside the bump. Leave them
alone; changing them is a separate pull request, not release work.
Review the version diff, but do not run the release tests or package build
locally as a matter of course. `tests.yml` runs the same test set and package
build on the pull request that `pypi-release.yml` will run on the tag, so the
pull request's GitHub checks are the verification gate. Reproduce a single
failure locally only when the workflow logs are not enough to fix it.
Commit the version change, push it to the fork, and open the pull request with
the GitHub CLI when those external actions are authorized:
```bash
git commit -am "Bump version to X.Y.Z"
git push -u <fork-remote> <release-branch>
gh pr create --repo OWNER/REPO \
--base <default-branch> \
--head <fork-owner>:<release-branch> \
--title "Bump version to X.Y.Z" \
--body "Prepare the vX.Y.Z release."
```
`gh pr create` refuses to run without `--title` and `--body` outside an
interactive terminal, and every `gh` call needs `--repo OWNER/REPO` so it acts
on the canonical repository rather than the fork.
Follow the pull request through its required checks with
`gh pr checks <pr> --repo OWNER/REPO --watch`. If a check fails, take the run id
from that output, inspect it with
`gh run view <run-id> --repo OWNER/REPO --log-failed`, correct the source on the
same branch, and resume watching. Once every required check has succeeded, merge
the pull request with `gh pr merge <pr> --repo OWNER/REPO` using a merge method
the repository permits. Committing, pushing, opening the pull request, and
merging are each distinct external actions and each requires authorization.
## Tag and publish the merged release
After the pull request merges, update the local default branch from the
canonical repository, then confirm that the commit you are about to tag is the
one this pull request produced and not a later commit that landed behind it:
```bash
git switch <default-branch>
git pull --ff-only <canonical-remote> <default-branch>
gh pr view <pr> --repo OWNER/REPO --json mergeCommit
git rev-parse HEAD
```
If HEAD has moved past the merge commit, tag the merge commit explicitly instead
of HEAD.
`pypi-release.yml` fails the release when the packaged version does not equal the
tag without its leading `v`, or when it does not equal the runtime
`__version__`. Check both before tagging:
```bash
uv version --short
grep '^__version__' agentlightning/__init__.py
```
The workflow itself reads the runtime value as
`python -c 'from agentlightning import __version__; print(__version__)'`, after
`uv sync` has installed the checkout. Locally that import can resolve to some
other installed copy of the package instead of the tree being tagged, so read
the file directly here; `agentlightning/__init__.py` assigns `__version__` as a
single literal, so the two agree by construction.
GitHub reads workflow files as they exist **at the tagged commit**, not at the
tip of the default branch. Confirm that the commit being tagged actually
contains `.github/workflows/pypi-release.yml` with its `v*` trigger; a commit
that predates the workflow will never publish, however the tag is pushed.
Immediately query the canonical repository and PyPI again to ensure that
`vX.Y.Z` is still absent. Then create an annotated tag on the release commit and
push it to the canonical repository:
```bash
git tag -a vX.Y.Z -m "vX.Y.Z" <release-commit>
git push <canonical-remote> vX.Y.Z
```
The tag push starts the production PyPI publication, so obtain explicit
authorization immediately before it.
## Follow both tag-triggered workflows
One tag push starts two workflows, and both belong to the release:
- `PyPI Release` (`pypi-release.yml`) re-checks the version against the tag,
runs the tests, builds the wheel and source distribution, and uploads them to
PyPI through trusted publishing.
- `Deploy Documentation` (`docs.yml`) runs
`mike deploy --push --update-aliases X.Y.Z stable`, which publishes the
versioned documentation and repoints the public `stable` alias at this
release.
Follow both to a terminal result with `gh run list --repo OWNER/REPO` and
`gh run watch <run-id> --repo OWNER/REPO --exit-status`. After `PyPI Release`
succeeds, verify that PyPI exposes the exact version with both the expected
wheel and source distribution. After `Deploy Documentation` succeeds, verify
that the published site serves `X.Y.Z` and that `stable` resolves to it. A green
PyPI job with a failed documentation job is a half-finished release: report both
workflow URLs and both outcomes.
For a transient workflow failure, rerun only with authorization. For a source
or workflow defect, do not move the public tag; prepare a corrective release
version. A GitHub Release and release notes are optional, separate publication
actions and must not be created unless requested.
## Recovering a tag that never published
Separate the mechanics from the policy before proposing a recovery.
The mechanics: pushing a tag that already exists and points at the same commit
changes no ref, so it starts no workflow run. Creating a tag, moving one to a
different commit, or deleting and recreating one does change the ref and does
start a run. What that run executes is the workflow file at the tagged commit,
so a tag on a commit from before `pypi-release.yml` existed starts no PyPI
publication no matter how it is pushed. Run
`git ls-tree --name-only <tag> .github/workflows/` before assuming a re-push
would help.
The policy: never move or reuse a tag whose version is on PyPI. That version is
immutable, so a re-run could only fail at upload, and consumers who already
resolved the tag would silently get different code.
Between those, a tag that never published is a decision for a release owner,
not a default action. Releasing the next version from a commit that carries the
current workflow is usually simpler and always safer than resurrecting the old
tag. Note that a non-publishing tag may still have had effects: `docs.yml` has
carried the `v*` trigger for longer than `pypi-release.yml`, so an older tag can
have deployed documentation and moved `stable` without ever reaching PyPI.
## Nightly distinction
`.github/workflows/pypi-nightly.yml` publishes timestamped `.dev` builds to
TestPyPI on its schedule or by manual dispatch. It does not create a stable
release and should not be substituted for the tag-driven process above.
@@ -0,0 +1,4 @@
interface:
display_name: "Release"
short_description: "Prepare and publish Agent Lightning releases"
default_prompt: "Use $release to prepare and publish a new Agent Lightning release."
+79 -12
View File
@@ -1,14 +1,81 @@
.venv
**/.venv
__pycache__
.git
# Version control / editor state
# Local Python environments and caches
# Local-only runtime/deploy state
.git/
.gitignore
**/node_modules
dist
build
.gitattributes
.vscode/
.idea/
.claude/
.DS_Store
**/.DS_Store
# Local Python environments and caches
.venv/
.venv.bak/
venv/
env/
ENV/
__pycache__/
**/__pycache__/
*.py[codz]
*.pyo
*.pyd
*.so
*.egg-info/
.eggs/
dist/
build/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.pyright/
.ipynb_checkpoints/
**/.ipynb_checkpoints/
.cache/
# Local-only runtime/deploy state
.local/
.env
docker
.pytest_cache
.vscode
**/*.log
examples/**/data
**/.env
.env.local
*.env.local
.envrc
tmp/
node_modules/
checkpoints/
artifacts/
logs/
**/logs/
*.log
*-debug.log
2026-*-debug.log
# Files not needed for runtime images
tests/
docs/
dev/
uv.lock
# Large example data and generated outputs
examples/*/data/
examples/*/outputs/
examples/*/wandb/
examples/*/mlruns/
wandb/
runs/
outputs/
mlruns/
# Archives and large packaged artifacts
*.zip
*.tar
*.tar.gz
*.tgz
*.tar.bz2
*.tar.xz
*.7z
agentlightning-main.zip
# Docs/dev generated artifacts
docs/refactor_review/public/
+11
View File
@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
groups:
github-actions:
patterns: ["*"]
schedule:
interval: "weekly"
cooldown:
default-days: 7
-32
View File
@@ -1,32 +0,0 @@
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
-29
View File
@@ -1,29 +0,0 @@
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 });
-29
View File
@@ -1,29 +0,0 @@
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 });
-29
View File
@@ -1,29 +0,0 @@
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 });
-29
View File
@@ -1,29 +0,0 @@
name: Badge - ChartQA
on:
workflow_run:
workflows:
- Examples - ChartQA
types: [completed]
workflow_dispatch:
permissions:
actions: read
contents: read
jobs:
badge:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'main') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const badgeAggregation = require('./scripts/badge_aggregation.js');
const dependencies = [
{ workflow: 'examples-chartqa.yml', label: 'chartqa', variants: ['stable'] },
];
await badgeAggregation({ github, context, core, dependencies });
-29
View File
@@ -1,29 +0,0 @@
name: Badge - Claude Code
on:
workflow_run:
workflows:
- Examples - Claude Code
types: [completed]
workflow_dispatch:
permissions:
actions: read
contents: read
jobs:
badge:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'main') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const badgeAggregation = require('./scripts/badge_aggregation.js');
const dependencies = [
{ workflow: 'examples-claude-code.yml', label: 'claude-code', variants: ['stable'] },
];
await badgeAggregation({ github, context, core, dependencies });
-29
View File
@@ -1,29 +0,0 @@
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 });
-45
View File
@@ -1,45 +0,0 @@
name: Badge - Examples
on:
workflow_run:
workflows:
- Examples - Calc-X
- Examples - Spider
- Examples - APO
- Examples - Unsloth
- Examples - Tinker
- Examples - Azure
- Examples - Claude Code
- Examples - RAG
- Examples - ChartQA
types: [completed]
workflow_dispatch:
permissions:
actions: read
contents: read
jobs:
badge:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'main') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const badgeAggregation = require('./scripts/badge_aggregation.js');
const dependencies = [
{ workflow: 'examples-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'] },
{ workflow: 'examples-claude-code.yml', label: 'examples-claude-code.stable', variants: ['stable'] },
{ workflow: 'examples-rag.yml', label: 'examples-rag.stable', variants: ['stable'] },
{ workflow: 'examples-chartqa.yml', label: 'examples-chartqa.stable', variants: ['stable'] },
];
await badgeAggregation({ github, context, core, dependencies });
-41
View File
@@ -1,41 +0,0 @@
name: Badge - Latest
on:
workflow_run:
workflows:
- Examples - Calc-X
- Examples - Spider
- Examples - APO
- Examples - Unsloth
- Examples - RAG
- Examples - Claude Code
- 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: 'examples-claude-code.yml', label: 'claude-code.latest', variants: ['latest'] },
{ workflow: 'examples-rag.yml', label: 'rag.latest', variants: ['latest'] },
{ workflow: 'tests-full.yml', label: 'tests-full.latest', variants: ['latest'] },
];
await badgeAggregation({ github, context, core, dependencies });
-29
View File
@@ -1,29 +0,0 @@
name: Badge - RAG
on:
workflow_run:
workflows:
- Examples - RAG
types: [completed]
workflow_dispatch:
permissions:
actions: read
contents: read
jobs:
badge:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.head_branch == 'main') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const badgeAggregation = require('./scripts/badge_aggregation.js');
const dependencies = [
{ workflow: 'examples-rag.yml', label: 'rag', variants: ['legacy', 'stable'] },
];
await badgeAggregation({ github, context, core, dependencies });
-29
View File
@@ -1,29 +0,0 @@
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'] },
];
await badgeAggregation({ github, context, core, dependencies });
-29
View File
@@ -1,29 +0,0 @@
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 });
-31
View File
@@ -1,31 +0,0 @@
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 });
-29
View File
@@ -1,29 +0,0 @@
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 });
-441
View File
@@ -1,441 +0,0 @@
name: Benchmark
permissions:
contents: read
on:
workflow_dispatch:
schedule:
# Every Monday and Thursday at 3 AM UTC+8
- cron: '0 19 * * 0,3'
jobs:
benchmark:
name: ${{ matrix.workload.kind }} (${{ matrix.backend.id }}, ${{ matrix.workload.display }})
runs-on: ${{ matrix.workload.runner }}
timeout-minutes: ${{ matrix.workload.timeout }}
strategy:
fail-fast: false
matrix:
backend:
- id: memory
compose_file: compose.prometheus-memory-store.yml
- id: mongo
compose_file: compose.prometheus-mongo-store.yml
workload:
- id: scenario-minimal-scale
display: Minimal production scale
kind: scenario
store_workers: 4
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu
timeout: 45
args: >-
--mode batch
--total-tasks 4096
--batch-size 256
--n-runners 32
--max-rounds 6
--sleep-seconds 0.5
- id: scenario-medium-scale
display: Medium production scale
kind: scenario
store_workers: 16
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu
timeout: 45
args: >-
--mode batch
--total-tasks 10000
--batch-size 1000
--n-runners 100
--max-rounds 10
--sleep-seconds 0.1
- id: scenario-midhigh-scale
display: Mid-high production scale
kind: scenario
store_workers: 24
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu
timeout: 60
args: >-
--mode batch
--total-tasks 20000
--batch-size 2048
--n-runners 256
--max-rounds 8
--sleep-seconds 0.1
- id: scenario-large-batch
display: Large batch waves
kind: scenario
store_workers: 64
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu-high
timeout: 120
args: >-
--mode batch
--total-tasks 50000
--batch-size 8192
--n-runners 256
--max-rounds 6
--sleep-seconds 0.1
- id: scenario-long-queues
display: Long rollout queues
kind: scenario
store_workers: 48
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu
timeout: 120
args: >-
--mode batch_partial
--total-tasks 50000
--batch-size 1024
--n-runners 256
--remaining-tasks 4096
--max-rounds 4
--sleep-seconds 0.1
- id: scenario-high-concurrency
display: High-throughput concurrent requests
kind: scenario
store_workers: 96
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu-high
timeout: 120
args: >-
--mode single
--total-tasks 50000
--concurrency 2048
--n-runners 256
--max-rounds 2
--sleep-seconds 0.1
- id: scenario-heavy-traces
display: Heavy rollouts with deep traces
kind: scenario
store_workers: 64
runner:
- self-hosted
- 1ES.Pool=agl-runner-cpu-high
timeout: 60
args: >-
--mode batch_partial
--total-tasks 10000
--batch-size 1024
--remaining-tasks 256
--n-runners 512
--max-rounds 20
--sleep-seconds 1.0
- id: micro-worker
display: Update worker
kind: micro
store_workers: 8
runner: ubuntu-latest
timeout: 30
cli: worker
- id: micro-dequeue-empty
display: Dequeue empty
kind: micro
store_workers: 8
runner: ubuntu-latest
timeout: 30
cli: dequeue-empty
- id: micro-rollout
display: Rollout + span
kind: micro
store_workers: 8
runner: ubuntu-latest
timeout: 30
cli: rollout
- id: micro-dequeue-update-attempt
display: Dequeue + update attempt
kind: micro
store_workers: 8
runner: ubuntu-latest
timeout: 30
cli: dequeue-update-attempt
- id: micro-dequeue-only
display: Dequeue only
kind: micro
store_workers: 8
runner: ubuntu-latest
timeout: 30
cli: dequeue-only
- id: micro-metrics
display: Multi-metric fan-out
kind: micro
store_workers: 8
runner: ubuntu-latest
timeout: 15
cli: metrics
env:
PYTHONUNBUFFERED: "1"
STORE_URL: http://localhost:4747
STORE_API_URL: http://localhost:4747/v1/agl
PROM_URL: http://localhost:9090
GITHUB_ACTIONS_TIMEOUT_MINUTES: ${{ matrix.workload.timeout }}
WORKLOAD_KIND: ${{ matrix.workload.kind }}
WORKLOAD_ID: ${{ matrix.workload.id }}
BACKEND_ID: ${{ matrix.backend.id }}
ARTIFACT_DIR: ${{ format('artifacts/{0}-{1}', matrix.workload.id, matrix.backend.id) }}
COMPOSE_FILE: ${{ matrix.backend.compose_file }}
AGL_STORE_N_WORKERS: ${{ matrix.workload.store_workers }}
ANALYSIS_FILE: ${{ format('analysis-{0}.log', matrix.workload.id) }}
SUMMARY_FILE: ${{ format('summary-{0}.log', matrix.workload.id) }}
PROM_ARCHIVE_BASENAME: ${{ format('prometheus-{0}-{1}', matrix.workload.id, matrix.backend.id) }}
ARTIFACT_NAME: ${{ format('{0}-{1}', matrix.workload.id, matrix.backend.id) }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
python-version: '3.12'
- name: Sync dependencies
run: uv sync --frozen --extra mongo --group core-stable --group dev
- name: Check disk space
run: df -h
- name: Reset benchmark data directories
run: |
set -euo pipefail
cd docker
rm -rf data
bash setup.sh
- name: Launch ${{ matrix.backend.id }} Prometheus stack
run: |
set -euo pipefail
cd docker
docker compose -f "$COMPOSE_FILE" down -v || true
docker compose -f "$COMPOSE_FILE" up -d --quiet-pull
- name: Wait for store readiness
run: |
set -euo pipefail
for attempt in {1..60}; do
if curl -fsS "$STORE_API_URL/health" >/dev/null 2>&1; then
sleep 1
curl -fsS "$STORE_API_URL/rollouts" # Warm up the scraper
sleep 15 # Allow some time for the baseline metrics to be established
exit 0
fi
sleep 1
done
echo "Store did not become ready in time" >&2
# show logs for debugging
cd docker && docker compose -f "$COMPOSE_FILE" logs app
exit 1
- name: Prepare artifact directory
run: mkdir -p "$ARTIFACT_DIR"
- name: Record workload start
run: echo "BENCHMARK_START=$(date -u +%FT%TZ)" >> "$GITHUB_ENV"
- name: (Scenario) Run ${{ matrix.workload.display }} workload
if: ${{ matrix.workload.kind == 'scenario' }}
run: |
set -euo pipefail
uv run --locked --no-sync python -m tests.benchmark.benchmark_store \
--store-url "$STORE_URL" \
${{ matrix.workload.args }}
- name: (Micro) Run ${{ matrix.workload.display }}
if: ${{ matrix.workload.kind == 'micro' }}
run: |
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
uv run --locked --no-sync python -m tests.benchmark.micro_benchmark \
--store-url "$STORE_URL" \
--summary-file "$ARTIFACT_DIR/$SUMMARY_FILE" \
"${{ matrix.workload.cli }}" | tee "$ARTIFACT_DIR/${{ matrix.workload.id }}.txt"
- name: Record workload end
if: ${{ always() }}
run: echo "BENCHMARK_END=$(date -u +%FT%TZ)" >> "$GITHUB_ENV"
- name: Show micro benchmark summary
if: ${{ always() && matrix.workload.kind == 'micro' }}
run: |
set -euo pipefail
summary_file="$ARTIFACT_DIR/$SUMMARY_FILE"
if [ -f "$summary_file" ]; then
echo "Micro benchmark summary ($WORKLOAD_ID/$BACKEND_ID):"
cat "$summary_file"
else
echo "Summary file not found: $summary_file"
fi
- name: Run workload analysis
if: ${{ always() }}
run: |
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
if [ -z "${BENCHMARK_START:-}" ] || [ -z "${BENCHMARK_END:-}" ]; then
echo "Analysis skipped: benchmark window not recorded." > "$ARTIFACT_DIR/$ANALYSIS_FILE"
exit 1
fi
uv run --locked --no-sync python -m tests.benchmark.analysis \
--prom-url "$PROM_URL" \
--store-url "$STORE_API_URL" \
--start "$BENCHMARK_START" \
--end "$BENCHMARK_END" \
| tee "$ARTIFACT_DIR/$ANALYSIS_FILE"
- name: Collect docker logs
if: ${{ always() }}
run: |
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
cd docker
readarray -t services < <(docker compose -f "$COMPOSE_FILE" config --services)
if [ "${#services[@]}" -eq 0 ]; then
echo "No services defined in compose file."
exit 0
fi
for service in "${services[@]}"; do
docker compose -f "$COMPOSE_FILE" logs "$service" > "../$ARTIFACT_DIR/docker-${service}-${WORKLOAD_ID}-${BACKEND_ID}.log" || true
done
- name: Stop ${{ matrix.backend.id }} Prometheus stack
if: ${{ always() }}
run: |
set -euo pipefail
cd docker
docker compose -f "$COMPOSE_FILE" down -v || true
- name: Archive Prometheus metrics
if: ${{ always() }}
run: |
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
if [ -d docker/data/prometheus ]; then
tar -C docker/data -czf "$ARTIFACT_DIR/${PROM_ARCHIVE_BASENAME}.tar.gz" prometheus
fi
- name: Upload workload artifacts
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: error
collection-benchmarks:
name: collection (${{ matrix.backend.id }}, ${{ matrix.workload.id }})
runs-on: ${{ matrix.backend.runner }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
backend:
- id: memory
needs_mongo: false
runner: ubuntu-latest
- id: mongo
needs_mongo: true
runner: ubuntu-latest
workload:
- id: high-insert
total_tasks: 50000
concurrency: 2048
type: insert
- id: medium-insert
total_tasks: 50000
concurrency: 128
type: insert
- id: low-insert
total_tasks: 50000
concurrency: 4
type: insert
- id: high-dequeue
total_tasks: 50000
concurrency: 2048
type: dequeue
- id: medium-dequeue
total_tasks: 50000
concurrency: 128
type: dequeue
- id: low-dequeue
total_tasks: 50000
concurrency: 4
type: dequeue
env:
ARTIFACT_DIR: ${{ format('artifacts/{0}-{1}', matrix.backend.id, matrix.workload.id) }}
SUMMARY_FILE: ${{ format('artifacts/{0}-{1}/summary-{0}-{1}.jsonl', matrix.backend.id, matrix.workload.id) }}
ARTIFACT_NAME: ${{ format('collections-{0}-{1}', matrix.backend.id, matrix.workload.id) }}
MONGO_URI: mongodb://localhost:27017/?replicaSet=rs0
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
python-version: '3.12'
- name: Sync dependencies
run: uv sync --frozen --extra mongo --group core-stable --group dev
- name: Launch MongoDB
if: ${{ matrix.backend.needs_mongo }}
run: |
set -euo pipefail
cd docker
docker compose -f compose.mongo.yml down -v || true
docker compose -f compose.mongo.yml up -d --quiet-pull
for attempt in {1..60}; do
if docker compose -f compose.mongo.yml exec -T mongo mongosh --quiet --eval 'db.runCommand({ping:1})' >/dev/null 2>&1; then
exit 0
fi
sleep 2
done
echo "MongoDB did not become ready in time" >&2
docker compose -f compose.mongo.yml logs mongo
exit 1
- name: Run collection benchmark
run: |
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
echo "Running collection benchmark (backend=${{ matrix.backend.id }}, workload=${{ matrix.workload.id }})"
uv run --locked --no-sync python -m tests.benchmark.collection_benchmark \
"${{ matrix.workload.type }}" \
--backend "${{ matrix.backend.id }}" \
--total-tasks "${{ matrix.workload.total_tasks }}" \
--concurrency "${{ matrix.workload.concurrency }}" \
--task-prefix "${{ matrix.backend.id }}-${{ matrix.workload.id }}" \
--summary-file "$SUMMARY_FILE" \
--mongo-uri "$MONGO_URI" \
--mongo-database agentlightning_collection_bench
- name: Show collection benchmark summary
if: ${{ always() }}
run: |
set -euo pipefail
if [ -f "$SUMMARY_FILE" ]; then
echo "Collection benchmark summary (${{ matrix.backend.id }}):"
cat "$SUMMARY_FILE"
else
echo "Summary file not found: $SUMMARY_FILE"
fi
- name: Stop MongoDB
if: ${{ always() && matrix.backend.needs_mongo }}
run: |
set -euo pipefail
cd docker
docker compose -f compose.mongo.yml down -v || true
- name: Upload collection artifacts
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: error
-33
View File
@@ -1,33 +0,0 @@
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
+4 -4
View File
@@ -21,17 +21,17 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
- uses: actions/setup-python@v6
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v7
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
- name: Sync dependencies
run: uv sync --frozen --no-default-groups --group dev
run: uv sync --frozen --no-default-groups --group dev --group docs
- name: Configure Git
run: |
-116
View File
@@ -1,116 +0,0 @@
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'
-98
View File
@@ -1,98 +0,0 @@
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
-411
View File
@@ -1,411 +0,0 @@
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 --extra weave --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 --extra weave --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 LoRA
run: |
set -ex
source .venv/bin/activate
cd examples/calc_x
../../scripts/restart_ray.sh
sleep 5
PYTHONUNBUFFERED=1 python train_calc_agent.py --val-file data/test_mini.parquet --ci-fast --lora
sleep 10
shell: bash
env:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
id: calc_x_train_lora
if: matrix.setup-script != 'legacy'
- name: Validate training with LoRA
run: |
set -ex
uv run scripts/validate_example_wandb.py ${{ steps.calc_x_train_lora.outputs.project_name }} ${{ steps.calc_x_train_lora.outputs.run_name }}
env:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
if: matrix.setup-script != 'legacy'
- name: Training with trajectory level aggregation
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 --trajectory-level
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_trajectory_level
- name: Validate training with trajectory level aggregation
run: |
set -ex
uv run scripts/validate_example_wandb.py ${{ steps.calc_x_train_trajectory_level.outputs.project_name }} ${{ steps.calc_x_train_trajectory_level.outputs.run_name }}
env:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
- name: Training with Weave
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 --weave
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_weave
- name: Validate training with Weave
run: |
set -ex
uv run scripts/validate_example_wandb.py ${{ steps.calc_x_train_weave.outputs.project_name }} ${{ steps.calc_x_train_weave.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 }}
-168
View File
@@ -1,168 +0,0 @@
name: Examples - ChartQA
permissions:
contents: read
on:
schedule:
# Every day at 6 AM UTC+8
- cron: "0 22 * * *"
workflow_dispatch:
repository_dispatch:
types: [ci-chartqa, ci-all]
run-name: >-
${{ github.event_name == 'repository_dispatch'
&& format(
'ChartQA - PR #{0} - {1} - {2}',
github.event.client_payload.pull_number,
github.event.client_payload.ci_label,
github.event.client_payload.correlation_id
)
|| format('ChartQA - {0}', github.event_name) }}
jobs:
chartqa:
if: >
github.event_name != 'repository_dispatch' ||
github.event.action == 'ci-chartqa' ||
github.event.action == 'ci-all'
name: ChartQA (Python ${{ matrix.python-version }}, ${{ matrix.setup-script }})
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
timeout-minutes: 60
strategy:
matrix:
include:
- python-version: '3.12'
setup-script: 'stable'
fail-fast: false
steps:
- name: Check GPU status
run: nvidia-smi
- name: Check disk space
run: df -h
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.pr_ref || (github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number)) || github.ref }}
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
- name: Upgrade dependencies (latest)
run: uv lock --upgrade
if: matrix.setup-script == 'latest'
- name: Sync dependencies
run: |
uv sync --frozen --no-default-groups --extra verl \
--group dev --group experiment --group image --group langchain --group vllm-0-10-2 --group torch-gpu-stable
- name: Freeze dependencies
run: |
set -ex
uv pip freeze | tee requirements-freeze.txt
echo "UV_LOCKED=1" >> $GITHUB_ENV
echo "UV_NO_SYNC=1" >> $GITHUB_ENV
- name: Upload dependencies artifact
uses: actions/upload-artifact@v4
with:
name: dependencies-chartqa-${{ matrix.python-version }}-${{ matrix.setup-script }}
path: requirements-freeze.txt
compression-level: 0
- name: Launch LiteLLM Proxy
run: |
./scripts/litellm_run.sh
env:
AZURE_API_BASE: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_BASE }}
AZURE_API_KEY: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_KEY }}
- name: Prepare ChartQA dataset
run: |
set -euo pipefail
cd examples/chartqa
uv run gdown --fuzzy "https://drive.google.com/file/d/1fWRt9hehg8_uV7BDWSCwKTycM60JcmGN/view?usp=sharing" -O chartqa-data.zip
unzip chartqa-data.zip
rm chartqa-data.zip
shell: bash
- name: ChartQA sanity check with GPT
run: |
set -euo pipefail
cd examples/chartqa
uv run python debug_chartqa_agent.py
shell: bash
env:
OPENAI_API_BASE: http://localhost:12306/
OPENAI_API_KEY: dummy
- name: Run vLLM Server
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/chartqa
uv run --no-sync vllm serve Qwen/Qwen2-VL-2B-Instruct \
--gpu-memory-utilization 0.9 \
--max-model-len 4096 \
--allowed-local-media-path "$(pwd)/data" \
--enable-prefix-caching \
--port 8088 &
VLLM_READY=0
for i in {1..100}; do
if curl -sSf http://localhost:8088/v1/models > /dev/null 2>&1; then
echo "vLLM server is ready!"
VLLM_READY=1
break
fi
echo "Waiting for vLLM server to be ready... (${i})"
sleep 5
done
if [[ "$VLLM_READY" != "1" ]]; then
echo "vLLM server failed to start!"
exit 1
fi
- name: ChartQA sanity check with vLLM
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/chartqa
uv run python debug_chartqa_agent.py
shell: bash
env:
USE_LLM_PROXY: "1"
OPENAI_API_BASE: http://localhost:8088/v1
OPENAI_MODEL: Qwen/Qwen2-VL-2B-Instruct
- name: Stop vLLM Server
run: |
set -euo pipefail
pkill -f vllm
for i in {1..60}; do
if ! pgrep -f vllm; then
break
fi
sleep 5
done
- name: ChartQA training
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/chartqa
../../scripts/restart_ray.sh
sleep 5
PYTHONUNBUFFERED=1 python train_chartqa_agent.py ci
sleep 10
shell: bash
env:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
id: chartqa_train
- name: Validate ChartQA training
run: |
set -euo pipefail
uv run scripts/validate_example_wandb.py ${{ steps.chartqa_train.outputs.project_name }} ${{ steps.chartqa_train.outputs.run_name }}
env:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
-151
View File
@@ -1,151 +0,0 @@
name: Examples - Claude Code
permissions:
contents: read
on:
schedule:
# Every day at 4 AM UTC+8
- cron: "0 20 * * *"
workflow_dispatch:
repository_dispatch:
types: [ci-claude-code, ci-all]
run-name: >-
${{ github.event_name == 'repository_dispatch'
&& format(
'Claude Code - PR #{0} - {1} - {2}',
github.event.client_payload.pull_number,
github.event.client_payload.ci_label,
github.event.client_payload.correlation_id
)
|| format('Claude Code - {0}', github.event_name) }}
jobs:
claude-code:
if: >
github.event_name != 'repository_dispatch' ||
github.event.action == 'ci-claude-code' ||
github.event.action == 'ci-all'
name: Claude Code (Python ${{ matrix.python-version }}, ${{ matrix.setup-script }})
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
timeout-minutes: 60
strategy:
matrix:
include:
- python-version: "3.12"
setup-script: "stable"
- 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 \
--group dev --group experiment --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-claude-code-${{ matrix.python-version }}-${{ matrix.setup-script }}
path: requirements-freeze.txt
compression-level: 0
- name: Download model
run: |
source .venv/bin/activate
python -c "from transformers import AutoModelForCausalLM; AutoModelForCausalLM.from_pretrained('Qwen/Qwen3-Coder-30B-A3B-Instruct')"
- name: Launch vLLM server
run: |
set -euo pipefail
source .venv/bin/activate
vllm serve Qwen/Qwen3-Coder-30B-A3B-Instruct \
--max-model-len 131072 \
--enable-auto-tool-choice \
--tool-call-parser qwen3_coder \
--port 45993 &
VLLM_READY=0
for i in {1..100}; do
if curl -sSf http://localhost:45993/v1/models > /dev/null 2>&1; then
echo "vLLM server is ready!"
VLLM_READY=1
break
fi
echo "Waiting for vLLM server to be ready... (${i})"
sleep 5
done
if [[ "$VLLM_READY" != "1" ]]; then
echo "vLLM server failed to start!"
exit 1
fi
- name: Claude Code sanity check with vLLM models
run: |
source .venv/bin/activate
cd examples/claude_code
python claude_code_agent.py vllm --backend-model-high Qwen/Qwen3-Coder-30B-A3B-Instruct --backend-model-low Qwen/Qwen3-Coder-30B-A3B-Instruct --base-url http://localhost:45993/v1 --debug
shell: bash
- name: Upload sanity check artifacts for vLLM
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: claude-code-sanity-check-vllm-${{ matrix.setup-script }}
path: |
examples/claude_code/data/
examples/claude_code/logs/
if-no-files-found: error
- name: Cleanup vLLM
run: |
set -euo pipefail
pkill -f vllm
for i in {1..60}; do
if ! pgrep -f vllm; then
break
fi
sleep 5
done
rm -rf examples/claude_code/data/
rm -rf examples/claude_code/logs/
- name: Claude Code sanity check with OpenAI models
run: |
source .venv/bin/activate
cd examples/claude_code
python claude_code_agent.py openai --backend-model-high gpt-5.1-codex-mini --backend-model-low gpt-4.1-mini --debug
shell: bash
env:
OPENAI_BASE_URL: ${{ secrets.AZURE_OPENAI_ENDPOINT_SWEDEN }}
OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY_SWEDEN }}
- name: Upload sanity check artifacts for OpenAI
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: claude-code-sanity-check-openai-${{ matrix.setup-script }}
path: |
examples/claude_code/data/
examples/claude_code/logs/
if-no-files-found: error
-151
View File
@@ -1,151 +0,0 @@
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 }}
-179
View File
@@ -1,179 +0,0 @@
name: Examples - RAG
permissions:
contents: read
on:
schedule:
# Every day at 6 AM UTC+8
- cron: '0 22 * * *'
workflow_dispatch:
repository_dispatch:
types: [ci-rag, ci-all]
run-name: >-
${{ github.event_name == 'repository_dispatch'
&& format(
'RAG - PR #{0} - {1} - {2}',
github.event.client_payload.pull_number,
github.event.client_payload.ci_label,
github.event.client_payload.correlation_id
)
|| format('RAG - {0}', github.event_name) }}
jobs:
rag:
if: >
github.event_name != 'repository_dispatch' ||
github.event.action == 'ci-rag' ||
github.event.action == 'ci-all'
name: RAG (Python ${{ matrix.python-version }}, ${{ matrix.setup-script }})
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
timeout-minutes: 60
strategy:
matrix:
include:
- python-version: '3.10'
setup-script: 'legacy'
- python-version: '3.12'
setup-script: 'stable'
- python-version: '3.13'
setup-script: 'latest'
fail-fast: false
steps:
- name: Check GPU status
run: nvidia-smi
- name: Check disk space
run: df -h
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.pr_ref || (github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number)) || github.ref }}
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
- name: Upgrade dependencies (latest)
run: uv lock --upgrade
if: matrix.setup-script == 'latest'
- name: Sync dependencies (latest)
run: |
uv sync --frozen --no-default-groups --extra verl \
--group dev --group experiment --group agents --group rag --group torch-gpu-stable
if: matrix.setup-script == 'latest'
- name: Sync dependencies (stable & legacy)
run: |
uv sync --frozen --no-default-groups --extra verl \
--group dev --group experiment --group agents --group rag --group torch-gpu-${{ matrix.setup-script }}
if: matrix.setup-script != 'latest'
- name: Freeze dependencies
run: |
set -ex
uv pip freeze | tee requirements-freeze.txt
echo "UV_LOCKED=1" >> $GITHUB_ENV
echo "UV_NO_SYNC=1" >> $GITHUB_ENV
- name: Upload dependencies artifact
uses: actions/upload-artifact@v4
with:
name: dependencies-rag-${{ matrix.python-version }}-${{ matrix.setup-script }}
path: requirements-freeze.txt
compression-level: 0
- name: Launch LiteLLM Proxy
run: |
./scripts/litellm_run.sh
env:
AZURE_API_BASE: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_BASE }}
AZURE_API_KEY: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_KEY }}
- name: Prepare RAG dataset
run: |
set -euo pipefail
cd examples/rag
mkdir -p data
uv run gdown --fuzzy "https://drive.google.com/file/d/1Pq4Ag8zVoN8gUtLu0LcBfY35Dm5zL0hq/view?usp=drive_link" -O data/dataset_tiny.parquet
uv run gdown --fuzzy "https://drive.google.com/file/d/1REXCpRLbeZu1KfWWKhIGEQe_WNHUOBkS/view?usp=drive_link" -O data/chunks_candidate_tiny.pkl
uv run gdown --fuzzy "https://drive.google.com/file/d/1f6P-h_8KSRhe5pqDHWbRQWvUhTygfZ-c/view?usp=drive_link" -O data/index_hnsw_faiss_n32e40_tiny.index
- name: Run WIKI Retriever MCP Server
run: |
set -euo pipefail
cd examples/rag
uv run python wiki_retriever_mcp.py &
for i in {1..20}; do
sleep 5
if nc -z localhost 8099; then
echo "MCP server is up!"
exit 0
else
echo "Waiting for MCP server to start..."
fi
done
echo "MCP server failed to start within expected time."
exit 1
- name: Run vLLM Server
run: |
set -euo pipefail
source .venv/bin/activate
vllm serve Qwen/Qwen2.5-1.5B-Instruct \
--enable-auto-tool-choice \
--tool-call-parser hermes \
--port 8000 &
VLLM_READY=0
for i in {1..100}; do
if curl -sSf http://localhost:8000/v1/models > /dev/null 2>&1; then
echo "vLLM server is ready!"
VLLM_READY=1
break
fi
echo "Waiting for vLLM server to be ready... (${i})"
sleep 5
done
if [[ "$VLLM_READY" != "1" ]]; then
echo "vLLM server failed to start!"
exit 1
fi
- name: Run RAG Sanity check
run: |
set -ex
source .venv/bin/activate
cd examples/rag
uv run python rag_agent.py
shell: bash
- name: Stop vLLM Server
run: |
set -euo pipefail
pkill -f vllm
for i in {1..60}; do
if ! pgrep -f vllm; then
break
fi
sleep 5
done
- name: RAG training
run: |
set -ex
source .venv/bin/activate
cd examples/rag
../../scripts/restart_ray.sh
sleep 5
PYTHONUNBUFFERED=1 python train_rag.py fast
sleep 10
shell: bash
env:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
id: rag_train
- name: Validate RAG training
run: |
set -ex
# Allow up to 5 rollouts to fail to produce rewards
uv run scripts/validate_example_wandb.py ${{ steps.rag_train.outputs.project_name }} ${{ steps.rag_train.outputs.run_name }} --reward-tolerance 5
env:
WANDB_BASE_URL: ${{ secrets.MSR_WANDB_BASE_URL }}
WANDB_API_KEY: ${{ secrets.MSR_WANDB_API_KEY }}
-126
View File
@@ -1,126 +0,0 @@
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:
# legacy is omitted because langchain doesn't work with legacy vllm versions
- 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 langchain --group torch-gpu-stable
if: matrix.setup-script == 'latest'
- name: Sync dependencies (stable)
run: |
uv sync --frozen --no-default-groups --extra verl \
--group dev --group experiment --group agents --group langchain --group torch-gpu-${{ matrix.setup-script }}
if: matrix.setup-script == 'stable'
- name: Freeze dependencies
run: |
set -ex
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 }}
-170
View File
@@ -1,170 +0,0 @@
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
# TODO: Currently only test the client tracer implementation.
- name: Tinker LLM sanity check (tracer text)
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/tinker
python -m tests.test_tinker_llm tracer-text
shell: bash
env:
TINKER_API_KEY: ${{ secrets.TINKER_API_KEY }}
- name: Tinker LLM sanity check (tracer tool)
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/tinker
python -m tests.test_tinker_llm tracer-tool
shell: bash
env:
TINKER_API_KEY: ${{ secrets.TINKER_API_KEY }}
- 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 }}
-129
View File
@@ -1,129 +0,0 @@
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 }}
-309
View File
@@ -1,309 +0,0 @@
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}'.`);
}
-18
View File
@@ -1,18 +0,0 @@
# Pre-defined workflow with workflow_dispatch trigger,
# convenient for testing and debugging.
name: Playground
permissions:
contents: read
on:
workflow_dispatch:
jobs:
playground:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run script
run: |
echo "Hello, world!"
+43 -34
View File
@@ -2,58 +2,67 @@ name: PyPI Nightly Build
on:
schedule:
# Run daily at 6:00 AM UTC+8
# Run daily at 6:00 AM UTC+8.
- cron: '0 22 * * *'
workflow_dispatch: # Allow manual trigger
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pypi-nightly
cancel-in-progress: false
jobs:
publish-test-pypi:
name: Publish nightly package to TestPyPI
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v7
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
- name: Sync dependencies
run: uv sync --frozen --no-default-groups --group 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
run: |
VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Current version: $VERSION"
- name: Create development version
id: version
shell: bash
run: |
# Create a dev version with timestamp
TIMESTAMP=$(date +%Y%m%d%H%M%S)
DEV_VERSION="${{ steps.get_version.outputs.version }}.dev$TIMESTAMP"
echo "Creating dev version: $DEV_VERSION"
./scripts/bump_version.sh "$DEV_VERSION"
set -euo pipefail
BASE_VERSION=$(uv version --short)
TIMESTAMP=$(date -u +%Y%m%d%H%M%S)
DEV_VERSION="${BASE_VERSION}.dev${TIMESTAMP}"
uv version --frozen "${DEV_VERSION}"
sed -i "s/^__version__ = \".*\"$/__version__ = \"${DEV_VERSION}\"/" agentlightning/__init__.py
echo "version=${DEV_VERSION}" >> "${GITHUB_OUTPUT}"
- name: Verify version consistency
shell: bash
run: |
set -euo pipefail
PACKAGE_VERSION=$(uv version --short)
RUNTIME_VERSION=$(python -c 'from agentlightning import __version__; print(__version__)')
if [[ "${PACKAGE_VERSION}" != "${RUNTIME_VERSION}" ]]; then
echo "Package version ${PACKAGE_VERSION} does not match runtime version ${RUNTIME_VERSION}." >&2
exit 1
fi
- name: Build package
run: |
uv build
run: uv build --no-sources
- name: Publish to Test PyPI
uses: pypa/gh-action-pypi-publish@release/v1
- name: Verify package contents
run: |
python -m tarfile -l dist/*.tar.gz
python -m zipfile -l dist/*.whl
- name: Publish ${{ steps.version.outputs.version }} to TestPyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
repository-url: https://test.pypi.org/legacy/
+46 -56
View File
@@ -3,79 +3,69 @@ name: PyPI Release
on:
push:
tags:
- 'v*' # Trigger on version tags like v1.0.0, v1.2.3, etc.
workflow_dispatch: # Allow manual trigger
- 'v*'
permissions:
contents: read
jobs:
check-version:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.get_version.outputs.version }}
tag_version: ${{ steps.get_tag.outputs.tag_version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from pyproject.toml
id: get_version
run: |
VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Package version: $VERSION"
- name: Get tag version
id: get_tag
run: |
TAG_VERSION=${GITHUB_REF#refs/tags/v}
echo "tag_version=$TAG_VERSION" >> $GITHUB_OUTPUT
echo "Tag version: $TAG_VERSION"
- name: Verify version matches tag
run: |
if [ "${{ steps.get_version.outputs.version }}" != "${{ steps.get_tag.outputs.tag_version }}" ]; then
echo "Error: Version in pyproject.toml (${{ steps.get_version.outputs.version }}) does not match tag (${{ steps.get_tag.outputs.tag_version }})"
exit 1
fi
echo "Version check passed!"
publish-pypi:
needs: check-version
name: Test, build, and publish package
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v7
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
- name: Sync dependencies
run: uv sync --frozen --no-default-groups --group 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: Verify version matches tag
shell: bash
run: |
set -euo pipefail
PACKAGE_VERSION=$(uv version --short)
TAG_VERSION="${GITHUB_REF_NAME#v}"
if [[ "${PACKAGE_VERSION}" != "${TAG_VERSION}" ]]; then
echo "Package version ${PACKAGE_VERSION} does not match tag ${GITHUB_REF_NAME}." >&2
exit 1
fi
- name: Verify version consistency
shell: bash
run: |
set -euo pipefail
PACKAGE_VERSION=$(uv version --short)
RUNTIME_VERSION=$(python -c 'from agentlightning import __version__; print(__version__)')
if [[ "${PACKAGE_VERSION}" != "${RUNTIME_VERSION}" ]]; then
echo "Package version ${PACKAGE_VERSION} does not match runtime version ${RUNTIME_VERSION}." >&2
exit 1
fi
- name: Sync test dependencies
run: uv sync --frozen --no-default-groups --extra dev --group dev
- name: Run tests
run: >-
uv run --locked --no-sync pytest -v --durations=20
tests/server
tests/controller
tests/test_package.py
tests/examples/test_swe_smith_images.py
- name: Build package
run: |
uv build
run: uv build --no-sources
- name: Verify package contents
run: |
uv run --locked --no-sync python -m tarfile -l dist/*.tar.gz
uv run --locked --no-sync python -m zipfile -l dist/*.whl
python -m tarfile -l dist/*.tar.gz
python -m zipfile -l dist/*.whl
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
+66
View File
@@ -0,0 +1,66 @@
name: Validate Agent Skills
permissions:
contents: read
on:
push:
branches: [main]
paths:
- 'skills/**'
- '.agents/skills/**'
- '.github/workflows/skills.yml'
pull_request:
branches: [main]
paths:
- 'skills/**'
- '.agents/skills/**'
- '.github/workflows/skills.yml'
workflow_dispatch:
jobs:
validate:
name: Validate Agent Skills and Claude plugin formats
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Validate Agent Skills format
shell: bash
run: |
set -euo pipefail
# Published skills live in skills/ and repository-local agent skills in
# .agents/skills/. Both use the <root>/<name>/SKILL.md layout.
mapfile -t SKILLS < <(
find skills .agents/skills -mindepth 2 -maxdepth 2 -name SKILL.md -printf '%h\n' | sort
)
if [ "${#SKILLS[@]}" -eq 0 ]; then
echo "No SKILL.md found under skills/ or .agents/skills/." >&2
exit 1
fi
for SKILL in "${SKILLS[@]}"; do
echo "::group::${SKILL}"
uvx --from 'skills-ref==0.1.1' agentskills validate "${SKILL}"
echo "::endgroup::"
done
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: '22'
- name: Validate Claude Code plugins
shell: bash
run: |
set -euo pipefail
mapfile -t PLUGINS < <(
find skills .agents/skills -mindepth 1 -maxdepth 1 -type d \
-exec test -f '{}/.claude-plugin/plugin.json' \; -print | sort
)
if [ "${#PLUGINS[@]}" -eq 0 ]; then
echo "No Claude Code plugin found under skills/ or .agents/skills/." >&2
exit 1
fi
for PLUGIN in "${PLUGINS[@]}"; do
echo "::group::${PLUGIN}"
npx --yes @anthropic-ai/claude-code@2.1.218 plugin validate "${PLUGIN}"
echo "::endgroup::"
done
-405
View File
@@ -1,405 +0,0 @@
name: GPU Test
permissions:
contents: read
on:
schedule:
# Every day at 5 AM UTC+8
- cron: '0 21 * * *'
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: Full Test (${{ matrix.mark.display-name }}, ${{ matrix.env.setup-script }}, Python ${{ matrix.env.python-version }})
runs-on: ${{ matrix.mark.runs-on }}
timeout-minutes: 30
strategy:
matrix:
mark:
- id: store
display-name: Store
pytest-mark: 'store' # store tests should not require gpu
runs-on: ubuntu-latest
has-gpu: false
# AgentOps needs to be separated because it injects tricky global state.
- id: agentops
display-name: AgentOps
pytest-mark: 'agentops' # including agentops+litellm tests here
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
has-gpu: true
# Similar for Weave.
- id: weave
display-name: Weave
pytest-mark: 'weave'
runs-on: ubuntu-latest # No GPU tests for Weave.
has-gpu: false
# Other tests that require GPU
- id: gpu
display-name: GPU required
pytest-mark: '(gpu or llmproxy) and not agentops'
runs-on: [self-hosted, 1ES.Pool=agl-runner-gpu]
has-gpu: true
# Other uncovered tests
- id: others
display-name: Others
pytest-mark: 'not store and not agentops and not weave and not gpu and not llmproxy'
runs-on: ubuntu-latest
has-gpu: false
env:
- python-version: '3.10'
setup-script: 'legacy'
- python-version: '3.12'
setup-script: 'stable'
- python-version: '3.13'
setup-script: 'latest'
fail-fast: false
steps:
- name: Check GPU status
if: matrix.mark.has-gpu
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.env.python-version }}
- name: Upgrade dependencies (latest)
run: uv lock --upgrade
if: matrix.env.setup-script == 'latest'
- name: Sync dependencies (latest, gpu)
if: matrix.env.setup-script == 'latest' && matrix.mark.has-gpu
run: uv sync --frozen --no-default-groups --extra apo --extra weave --extra mongo --group dev --group agents --group langchain --group torch-gpu-stable
# Don't install vllm/pytorch on CPU counterparts
- name: Sync dependencies (latest, cpu)
if: matrix.env.setup-script == 'latest' && !matrix.mark.has-gpu
run: uv sync --frozen --no-default-groups --extra apo --extra weave --extra mongo --group dev --group agents --group langchain --group core-stable
- name: Sync dependencies (stable, gpu)
if: matrix.env.setup-script == 'stable' && matrix.mark.has-gpu
run: uv sync --frozen --no-default-groups --extra apo --extra weave --extra mongo --group dev --group agents --group langchain --group torch-gpu-${{ matrix.env.setup-script }}
- name: Sync dependencies (stable, cpu)
if: matrix.env.setup-script == 'stable' && !matrix.mark.has-gpu
run: uv sync --frozen --no-default-groups --extra apo --extra weave --extra mongo --group dev --group agents --group langchain --group core-stable
# Don't install langchain for legacy dependency because it has conflicts with torch.
- name: Sync dependencies (legacy, gpu)
if: matrix.env.setup-script == 'legacy' && matrix.mark.has-gpu
run: uv sync --frozen --no-default-groups --extra apo --extra weave --extra mongo --group dev --group agents --group torch-gpu-legacy
- name: Sync dependencies (legacy, cpu)
if: matrix.env.setup-script == 'legacy' && !matrix.mark.has-gpu
run: uv sync --frozen --no-default-groups --extra apo --extra weave --extra mongo --group dev --group agents --group core-legacy
- 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-tests-full-${{ matrix.mark.id }}-${{ matrix.env.python-version }}-${{ matrix.env.setup-script }}
path: requirements-freeze.txt
compression-level: 0
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: dashboard/package-lock.json
- name: Install JavaScript dependencies
run: cd dashboard && npm ci
- name: Build dashboard
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 }}
# mongo, openai, gpu, all enabled by default
- name: Run tests
run: |
uv run pytest -v --durations=0 tests -m "${{ matrix.mark.pytest-mark }}${{ matrix.env.setup-script == 'legacy' && ' and not langchain' || '' }}"
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 langchain --group torch-gpu-stable
if: matrix.setup-script == 'latest'
- name: Sync dependencies (stable)
run: uv sync --frozen --no-default-groups --extra apo --extra mongo --group dev --group agents --group langchain --group torch-gpu-${{ matrix.setup-script }}
if: matrix.setup-script == 'stable'
# Don't install langchain for legacy dependency because it has conflicts with torch.
- name: Sync dependencies (legacy)
run: uv sync --frozen --no-default-groups --extra apo --extra mongo --group dev --group agents --group torch-gpu-legacy
if: matrix.setup-script == 'legacy'
- name: Freeze dependencies
run: |
set -ex
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: |
./scripts/litellm_run.sh
env:
AZURE_API_BASE: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_BASE }}
AZURE_API_KEY: ${{ secrets.AZURE_GROUP_SUBSCRIPTION_API_KEY }}
- name: Write Traces via Otel Tracer
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/minimal
python write_traces.py otel
sleep 5
- name: Write Traces via AgentOps Tracer
env:
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 with Operations
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/minimal
python write_traces.py operation
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
- name: MultiMetrics backend example
run: |
set -euo pipefail
source .venv/bin/activate
cd examples/minimal
python write_metrics.py --duration 8 --prom-port 9105 --prom-host 0.0.0.0 2>&1 | tee metrics.log &
pid=$!
for attempt in $(seq 1 20); do
if curl -sSf http://localhost:9105/metrics | grep -q minimal_requests_total; then
echo "Metrics endpoint responding"
wait $pid
cat metrics.log
exit 0
fi
sleep 1
done
echo "Metrics endpoint did not respond"
exit 1
+80 -198
View File
@@ -1,235 +1,117 @@
name: CPU Test
name: Test
permissions:
contents: read
on:
push:
branches: [ main, stable/**/* ]
branches: [main]
pull_request:
branches: [ main, stable/**/* ]
branches: [main]
workflow_dispatch:
schedule:
# Every day at noon and midnight
- cron: '0 0,12 * * *'
concurrency:
group: test-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
strategy:
matrix:
setup: [fast, slow, next]
fail-fast: false
name: Lint - ${{ matrix.setup }}
name: Lint Python and repository files
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v7
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
python-version: '3.12'
- name: Sync dependencies (fast)
run: uv sync --frozen --group dev --no-default-groups
if: matrix.setup == 'fast'
- name: Upgrade dependencies (next)
run: uv lock --upgrade
if: matrix.setup == 'next'
- name: Sync dependencies (slow)
run: |
uv sync --frozen \
--extra apo \
--extra weave \
--extra verl \
--extra mongo \
--group dev \
--group torch-cpu \
--group torch-stable \
--group trl \
--group tinker \
--group agents \
--group langchain \
--no-default-groups
if: matrix.setup != 'fast'
# This pre-commit skips JavaScript on purpose.
- name: Run pre-commit
uses: pre-commit/action@v3.0.1
- name: Sync lint dependencies
run: uv sync --frozen --no-default-groups --extra dev --group dev
- name: Run pre-commit checks
run: uv run --locked --no-sync pre-commit run --all-files --show-diff-on-failure
- name: Run Ruff
run: uv run --locked --no-sync ruff check .
- name: Check Ruff formatting
run: uv run --locked --no-sync ruff format --check .
- name: Check Python headers
run: uv run --locked --no-sync scripts/check_headers.py
- name: Run Black
run: uv run --locked --no-sync black --check .
- name: Run isort
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 != 'fast'
run: uv run --locked --no-sync python scripts/check_headers.py
lint-js:
name: Lint - JavaScript
typecheck:
name: Type-check Python
runs-on: ubuntu-latest
# Split from `lint` because the verl-cpu group pulls torch and friends.
# Pyright needs them to check agentlightning/verl and tests/verl; the fast
# checks above should not wait on that download.
timeout-minutes: 20
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
- name: Sync type-check dependencies
run: uv sync --frozen --no-default-groups --extra dev --group dev --group verl-cpu
- name: Run Pyright
run: uv run --locked --no-sync pyright
test:
name: Run tests
runs-on: ubuntu-latest
# verl-cpu is needed for tests/verl; the rest of the suite only needs dev.
timeout-minutes: 20
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
- name: Sync test dependencies
run: uv sync --frozen --no-default-groups --extra dev --group dev --group verl-cpu
- name: Run tests
run: uv run --locked --no-sync pytest -v --durations=20 tests
package:
name: Build package
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: dashboard/package-lock.json
- name: Install dependencies
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
python-version: '3.12'
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
- name: Build package
run: uv build --no-sources
- name: Verify package contents
run: |
python -m tarfile -l dist/*.tar.gz
python -m zipfile -l dist/*.whl
docs:
name: Build documentation
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
- uses: actions/setup-python@v6
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v7
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
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: Verify OpenAPI specification is up-to-date
run: |
uv run --locked --no-sync python scripts/export_openapi.py
git diff --exit-code docs/assets/store-openapi.json
- name: Sync documentation dependencies
run: uv sync --frozen --no-default-groups --group docs
- name: Build documentation
env:
SOURCE_COMMIT: ${{ github.sha }}
run: uv run --locked --no-sync mkdocs build --strict
- name: Upload docs artifact
uses: actions/upload-artifact@v4
with:
name: documentation-site
path: site/
compression-level: 6
test:
strategy:
matrix:
mark:
# store has many tests and is a good isolated group.
- id: store
display-name: Store
pytest-mark: 'store'
# AgentOps needs to be separated because it injects tricky global state.
- id: agentops
display-name: AgentOps
pytest-mark: 'agentops'
# Similar for Weave.
- id: weave
display-name: Weave
pytest-mark: 'weave'
# litellm proxy tests are slow
- id: llmproxy
display-name: LLM proxy
pytest-mark: 'llmproxy'
# Robustness of utilities is important. There are many tests.
- id: utils
display-name: Utilities
pytest-mark: 'utils'
# unmarked tests: adapter, execution engine, etc.
- id: others
display-name: Others
pytest-mark: 'not store and not agentops and not weave and not llmproxy and not utils'
env:
- 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'
fail-fast: false
name: Test (${{ matrix.mark.display-name }}, ${{ matrix.env.setup-script }}, Python ${{ matrix.env.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
python-version: ${{ matrix.env.python-version }}
- name: Upgrade dependencies (latest)
run: uv lock --upgrade
if: matrix.env.setup-script == 'latest'
- name: Sync dependencies (latest)
run: uv sync --frozen --no-default-groups --extra apo --extra weave --group dev --group agents --group langchain --group core-stable
if: matrix.env.setup-script == 'latest'
- name: Sync dependencies (stable & legacy)
run: uv sync --frozen --no-default-groups --extra apo --extra weave --group dev --group agents --group langchain --group core-${{ matrix.env.setup-script }}
if: matrix.env.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-${{ matrix.mark.id }}-${{ matrix.env.python-version }}-${{ matrix.env.setup-script }}
path: requirements-freeze.txt
compression-level: 0
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: dashboard/package-lock.json
- name: Install JavaScript dependencies
run: cd dashboard && npm ci
- name: Build dashboard
run: cd dashboard && npm run build
- name: Run tests
run: |
uv run pytest -v --durations=0 tests -m "not mongo and not openai and not gpu and (${{ matrix.mark.pytest-mark }})"
env:
PYTEST_ADDOPTS: "--color=yes"
test-js:
name: Test (JavaScript)
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'
cache: 'npm'
cache-dependency-path: dashboard/package-lock.json
- 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
+105 -78
View File
@@ -1,17 +1,9 @@
# Agentlightning specific files
verl_old
meta-llama/**
**/debug/**/*.png
**/debug/**/*.json
requirements-freeze*.txt
/playground
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
# Distribution / packaging
__pycache__/
*.py[codz]
*$py.class
*.so
# Distribution / packaging
@@ -35,11 +27,6 @@ share/python-wheels/
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
@@ -54,26 +41,29 @@ htmlcov/
nosetests.xml
coverage.xml
*.cover
*.py,cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
# Django stuff
*.log
!examples/math-poc/reference_output.log
!examples/math-poc/reference_output_vllm.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
# Flask stuff
instance/
.webassets-cache
# Scrapy stuff:
# Scrapy stuff
.scrapy
# Sphinx documentation
@@ -85,46 +75,54 @@ target/
# Jupyter Notebook
.ipynb_checkpoints
.ipynb_checkpoints/
*.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that do not work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
@@ -135,14 +133,23 @@ celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
# Environments and local settings
.env
**/.env
.env.local
*.env.local
.envrc
.local/
.venv
.venv/
.venv.bak/
env/
venv/
ENV/
env.bak/
venv.bak/
.claude/
auto_docs/
# Spyder project settings
.spyderproject
@@ -154,67 +161,87 @@ venv.bak/
# mkdocs documentation
/site
# mypy
# mypy / pyright / pyre / pytype
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyright/
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# MacOS
.DS_Store
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the enitre vscode folder
.vscode/
# Emacs backup files
*~
# Ruff stuff:
# Ruff stuff
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
# Cursor ignore files can contain local/sensitive context selection.
.cursorignore
.cursorindexingignore
# Claude
.claude/*.local.json
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Dashboard generated files
agentlightning/dashboard/**/*.css
agentlightning/dashboard/**/*.js
agentlightning/dashboard/**/*.html
agentlightning/dashboard/**/*.svg
# Runtime logs and generated outputs
logs/
examples/*/logs/
.vscode/examples/math-poc/logs/
artifacts/
publicartifacts/
checkpoints/
wandb/
runs/
outputs/
mlruns/
*.ckpt
*.pt
*.pth
*.bin
*.safetensors
# Docker data
docker/data/
# Data not maintained in the repo
examples/calc_x/data/
!examples/calc_x/data/sample.jsonl
examples/calc_x/logs/
# Site/build output
site/
public/
# Archives / packaged artifacts
*.zip
*.tar
*.tar.gz
*.tgz
*.tar.bz2
*.tar.xz
*.7z
# Editor / OS
.vscode/
.idea/
.DS_Store
**/.DS_Store
*.swp
*.swo
*~
# Local example datasets
examples/**/*dataset*.jsonl
examples/**/subset*.jsonl
examples/**/verified_*.jsonl
# SWE-smith rollout stats
examples/swe_smith/rollout_stats.json
+4 -65
View File
@@ -1,77 +1,16 @@
exclude: ^(\.agents/|examples/llm-in-sandbox/vendor/)
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: end-of-file-fixer
exclude: (.*store-openapi\.json$)
- id: trailing-whitespace
- id: check-yaml
exclude: ^mkdocs\.yml$
exclude: ^(mkdocs\.yml|examples/calc_x/job-template\.yaml|examples/llm-in-sandbox/job-template\.yaml|examples/swe_smith/job-template-openai\.yaml)$
- id: check-toml
- id: check-added-large-files
args: ["--maxkb=1024"]
exclude: (^uv\.lock$)|(^docs/assets/.*\.svg$)|(.*store-openapi\.json$)
exclude: ^uv\.lock$
- id: check-shebang-scripts-are-executable
- id: detect-private-key
- repo: https://github.com/pycqa/isort
rev: 6.0.1
hooks:
- id: isort
args: ["."]
- repo: https://github.com/psf/black
rev: 25.1.0
hooks:
- id: black
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
'
-41
View File
@@ -1,41 +0,0 @@
# Repository Guidelines
## Architecture Overview
Agent Lightning runs through a continuous loop: runners and tracers emit spans, `LightningStore` (`agentlightning/store/`) keeps them synchronized, and algorithms in `agentlightning/algorithm/` consume those traces to improve behavior.
## Project Structure & Module Organization
- `agentlightning/`: adapters, execution stack, training loop, tracer, reward logic, and the `agl` CLI.
- `docs/` & `examples/`: narrative and procedural docs (assets in `docs/assets/`, navigation in `mkdocs.yml`) plus runnable workflows whose READMEs point to their companion how-to guides. `docs/how-to` covers task-focused instructions, while `docs/tutorials` explains concepts and subsystems.
- `dashboard/`, `scripts/`, `tests/`: UI bundles, release/dataset/CI automation, and mirrored coverage of the runtime tree. Record download steps rather than committing binaries.
## Build, Test, and Development Commands
- `uv sync --group dev` — provision tooling once per environment.
- `uv run --no-sync pytest -v` — execute the full suite; add a path or `-k expr` to narrow the run.
- `uv run --no-sync pyright` — enforce static typing parity with CI.
- `uv run --no-sync pre-commit run --all-files --show-diff-on-failure` and `uv run --no-sync mkdocs build --strict` — keep formatting tidy and documentation valid.
Always commit the refreshed `uv.lock` when dependencies shift, and mention optional groups (VERL, APO, GPU) in PR notes.
## Common Issues & Fixes
- When `uv run` errors with `Permission denied` under `~/.cache`, override both cache locations inline: ``UV_CACHE="$(pwd)/.cache_uv" XDG_CACHE_HOME="$(pwd)/.cache_xdg" uv run --no-sync <command>``.
## Coding Style & Naming Conventions
- Target `requires-python >= 3.10`, four-space indentation, 120-character lines (though docstrings may run longer), and formatter-owned diffs (Black + isort, `black` profile). Use `snake_case` for modules, functions, and variables; `PascalCase` for classes and React components; lowercase hyphenation for CLI flags, branch names, and TypeScript filenames.
- Maintain exhaustive type hints (pyright enforces them) and prefer shared dataclasses or Pydantic models from `agentlightning.types`.
- Author Google-style docstrings for new modules or public methods—succinct descriptions, no redundant type info, no redundant `Key features/components` bullet points. Use mkdocs styles: `[][]` syntax for cross-references and single backticks for inline code blocks.
- Writing logs is encouraged, especially for long functions with multiple steps and try-except blocks that catch all exceptions. Use `logging.getLogger(__name__)` to get loggers. Distinguish between DEBUG, INFO, WARNING, and ERROR logs.
## Testing Guidelines
- Mirror runtime directories under `tests/` and match filenames for quick traceability.
- Parametrize pytest cases and apply markers (`openai`, `gpu`, `agentops`, `mongo`, `llmproxy`) so optional suites can be skipped via selectors like `-m "not mongo"` yet still exercised in CI.
- Lean on fixtures, favor real stores/spans/agents over mocks, and drive coverage across the majority of branches.
- If an imported module is missing from the environment, check whether `uv sync` has been run with the right groups. Do not make stubs for external dependencies unless necessary.
## Example Contributions
- Ship each example with a README that includes smoke-test instructions so maintainers can validate quickly. The README must contain an "Included Files" section summarizing every file and its role.
- Keep runnable example modules self-contained with a module-level docstring describing CLI usage. Document important or educational classes/functions with targeted docstrings and inline comments where clarity matters.
- Add a CI workflow per example named `examples-<name>.yml` in `.github/workflows/`. Register it in `badge-<name>.yml`, `badge-examples.yml`, and `badge-latest.yml` when applicable so badges stay accurate.
## Commit & Pull Request Guidelines
- Branch from a fresh `main` using `feature/<slug>`, `fix/<slug>`, `docs/<slug>`, or `chore/<slug>`.
- Write imperative, scoped commits, reference issues with `Fixes #123`, and rerun pre-commit plus the relevant pytest/doc builds before pushing.
- Use PR descriptions to summarize intent, list verification commands, call out dependency or docs-navigation updates, and link new docs/examples via `mkdocs.yml` or `examples/README.md`. Include logs for dashboard changes.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
-77
View File
@@ -1,77 +0,0 @@
# Responsible AI Transparency Documentation - Agent Lightning
## OVERVIEW
Agent Lightning is a flexible and extensible framework that enables seamless agent optimization for any existing agent framework. Agent optimization includes various data-driven techniques to customize the agent for better performance, including but not limited to model fine-tuning, prompt tuning, and model selection. And the agent frameworks refer to popular and easy-to-use agent developing frameworks such as OpenAI Agents SDK, Microsoft AutoGen, and LangChain.
### WHAT CAN AGENT LIGHTNING DO
Agent lightning was developed to bridge the gap between agent workflow development and agent optimization, empowering developers to go beyond static, pre-trained models and unlock the full potential of adaptive, learning-based agents. Agent Lightning is a training framework which can be used for any LLMs.
### INTENDED USES
Agent Lightning is best suited for agent researchers and developers. They can easily fine-tune models in existing agent frameworks with Agent Lightning. This can improve model performance on the targeted scenarios.
### OUT-OF-SCOPE USES
Agent Lightning is not well-suited for users who are not familiar with agent development and machine learning concepts.
We do not recommend using Agent Lightning in commercial or real-world applications without further testing and development. It is being released for research purposes.
Agent Lightning was not designed or evaluated for all possible downstream purposes. Developers should consider its inherent limitations as they select use cases, and evaluate and mitigate for accuracy, safety, and fairness concerns specific to each intended downstream use.
Agent Lightning should not be used in highly regulated domains where inaccurate outputs could suggest actions that lead to injury or negatively impact an individual's legal, financial, or life opportunities.
We do not recommend using Agent Lightning in the context of high-risk decision making (e.g. in law enforcement, legal, finance, or healthcare).
## HOW TO GET STARTED
To begin using Agent Lightning, here are some instructions.
1. Install dependencies, including Python, uv, PyTorch, FlashAttention, vLLM, verl.
2. Clone and install Agent Lightning.
3. Convert the dataset (provided by the user) into parquet file, which contains multiple columns. Each column contains a data id, an input and an expected output.
4. Run agent, which is developed by the user.
5. Run the training process via “bash train.sh”
## EVALUATION
Agent Lightning was evaluated on its ability to correctly complete 3 example tasks: (1) Math. The model needs to answer some math questions, and when answering one question, the model can use the calculator as its tool to help answer. (2) Text2SQL. The model is given a question related to the database, and it is required to generate a SQL which can query the database, find the information to answer the question. (3) Retrieval-Augmented Generation (RAG). The model is given a question which needs some information from Wikipedia to answer. The model is required to generate some queries to find the related information in Wikipedia, and answer the question according to retrieved documents.
### EVALUATION METHODS AND RESULTS
For detailed evaluation methods and results, please refer to the latest version of our [technical report](https://arxiv.org/abs/2508.03680).
## LIMITATIONS
Agent Lightning was developed for research and experimental purposes. Further testing and validation are needed before considering its application in commercial or real-world scenarios.
Agent Lightning was designed and tested using the English language. Performance in other languages may vary and should be assessed by someone who is both an expert in the expected outputs and a native speaker of that language.
Outputs generated by AI may include factual errors, fabrication, or speculation. Users are responsible for assessing the accuracy of generated content. All decisions leveraging outputs of the system should be made with human oversight and not be based solely on system outputs.
Agent Lightning inherits any biases, errors, or omissions produced by its base model. Developers are advised to choose an appropriate base LLM/MLLM carefully, depending on the intended use case.
We use some demo cases to show the effectiveness of our training framework. See their links to understand the capabilities and limitations of this model.
## BEST PRACTICES
Better performance can be achieved by following the instructions in how to get started section.
We strongly encourage users to use LLMs/MLLMs that support robust Responsible AI mitigations, such as Azure Open AI (AOAI) services. Such services continually update their safety and RAI mitigations with the latest industry standards for responsible use. For more on AOAIs best practices when employing foundations models for scripts and applications:
- [Blog post on responsible AI features in AOAI that were presented at Ignite 2023](https://techcommunity.microsoft.com/t5/ai-azure-ai-services-blog/announcing-new-ai-safety-amp-responsible-ai-features-in-azure/ba-p/3983686)
- [Overview of Responsible AI practices for Azure OpenAI models](https://learn.microsoft.com/en-us/legal/cognitive-services/openai/overview)
- [Azure OpenAI Transparency Note](https://learn.microsoft.com/en-us/legal/cognitive-services/openai/transparency-note)
- [OpenAIs Usage policies](https://openai.com/policies/usage-policies)
- [Azure OpenAIs Code of Conduct](https://learn.microsoft.com/en-us/legal/cognitive-services/openai/code-of-conduct)
Users are responsible for sourcing their datasets legally and ethically. This could include securing appropriate rights, ensuring consent for use of audio/images, and/or the anonymization of data prior to use in research.
Users are reminded to be mindful of data privacy concerns and are encouraged to review the privacy policies associated with any models and data storage solutions interfacing with Agent Lightning.
It is the users responsibility to ensure that the use of Agent Lightning complies with relevant data protection regulations and organizational guidelines.
## LICENSE
We use the MIT license.
## CONTACT
We welcome feedback and collaboration from our audience. If you have suggestions, questions, or observe unexpected/offensive behavior in our technology, please contact us at agent-lightning@microsoft.com.
If the team receives reports of undesired behavior or identifies issues independently, we will update this repository with appropriate mitigations.
---
*Last updated: September 6, 2025*
*Document version: 1.0*
+81 -56
View File
@@ -1,51 +1,84 @@
<p align="center">
<img src="docs/assets/readme-banner.svg" alt="Agent-lightning-banner" style="width:600px"/>
<img src="docs/images/agl-v1.0.svg" alt="Agent Lightning v1.0" width="500">
</p>
# Agent Lightning⚡
[![Unit Tests](https://github.com/microsoft/agent-lightning/actions/workflows/badge-unit.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/badge-unit.yml)
[![Documentation](https://img.shields.io/badge/GitHub%20Pages-Documentation-blue)](https://microsoft.github.io/agent-lightning/)
[![PyPI version](https://badge.fury.io/py/agentlightning.svg)](https://badge.fury.io/py/agentlightning)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/microsoft/agent-lightning)
[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/RYk7CdvDR7)
**The absolute trainer to light up AI agents.**
Join our [Discord community](https://discord.gg/RYk7CdvDR7) to connect with other users and contributors.
## ⚡ 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, 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 **Algorithms** like Reinforcement Learning, Automatic Prompt Optimization, Supervised Fine-tuning and more. 🤗
Read more on our [documentation website](https://microsoft.github.io/agent-lightning/).
<p align="center"><em>3,500-Line Lightweight Agentic RL Framework for Training Agents with Real Harnesses!</em></p>
<p align="center">
<img src="docs/assets/readme-diff.svg" alt="Agent-Lightning Core Quickstart" style="width:100%"/>
<a href="https://microsoft.github.io/agent-lightning/stable/">Documentation</a> &nbsp;·&nbsp; <a href="https://arxiv.org/pdf/2608.17528">Technical Report</a> &nbsp;·&nbsp; <a href="LICENSE">MIT License</a>
</p>
> Agent Lightning was completely refactored in v1.0. For legacy releases earlier than v1.0, see [this branch](https://github.com/microsoft/agent-lightning/tree/v0.x).
## ⚡ Key Features
- 🪶 **~3,500 lines of code:** We treat simplicity as the first principle.
- 🧩 **Train with real agent harnesses:** Agents interact with the model through the Agent Lightning v1.0 proxy with **ZERO changes**, while keeping tools, context, control flow, and environments in the loop.
- ☸️ **Native Kubernetes support:** Run agents directly as Kubernetes Jobs without relying on external sandbox services.
- 💻 **Full coding agent training example:** Using only **6K training samples**, an end-to-end Qwen3.5-9B workflow improves SWE-bench Verified from **41.8% to 56.4%**, a gain of **14.6 percentage points**. We release the full pipeline, including data cleaning, reward-hacking prevention, and training scripts.
## ⚡ Installation
```bash
pip install agentlightning
```
For the latest nightly build (cutting-edge features), you can install from Test PyPI:
The following is an example installation on a CUDA 13.0 machine:
```bash
pip install --upgrade --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ agentlightning
cd <this-repo>
uv sync
bash scripts/setup_verl.sh 0.8.0 cu130
```
Please refer to our [installation guide](https://microsoft.github.io/agent-lightning/stable/tutorials/installation/) for more details.
See the [Installation Guide](https://microsoft.github.io/agent-lightning/stable/00-installation/) for details.
To start using Agent-lightning, check out our [documentation](https://microsoft.github.io/agent-lightning/) and [examples](./examples).
## ⚡ Architecture
<p align="center">
<img src="docs/images/architecture.jpg" alt="Agent Lightning v1.0 architecture" width="800">
</p>
Agent Lightning v1.0 keeps the training architecture simple with three lightweight components:
- **Trainer:** Runs `verl` and vLLM, builds training samples, and updates the policy.
- **API Gateway:** Proxies model requests and captures training data.
- **Rollout Controller:** Runs agents locally or as Kubernetes Jobs.
The Trainer creates rollouts, the Controller launches agents, and the Gateway turns interactions into training data, while agents continue to run with their real harnesses.
## ⚡ Results
We evaluate Agent Lightning v1.0 across several practical training domains, including Search R1, LLM-in-Sandbox, and Coding Agent. Pure RL delivers substantial improvements across all three domains, as shown below.
<p align="center">
<img src="docs/images/benchmark-comparison.jpg" alt="Agent Lightning v1.0 benchmark comparison" width="600">
</p>
## ⚡ Documentation
| Section | Content |
|---------|---------|
| [Installation](https://microsoft.github.io/agent-lightning/stable/00-installation/) | Base environment and `verl` GPU stack |
| [Quick Start](https://microsoft.github.io/agent-lightning/stable/01-quick-start/) | Local first run and end-to-end flow |
| [Basics](https://microsoft.github.io/agent-lightning/stable/05-basics/) | Components, rollouts, events, and trajectories |
| [Trainer Configuration](https://microsoft.github.io/agent-lightning/stable/20-trainer-configuration/) | `verl` integration and trace aggregation |
| [API Gateway Configuration](https://microsoft.github.io/agent-lightning/stable/25-api-gateway-configuration/) | Gateway and model proxy settings |
| [Controller Configuration](https://microsoft.github.io/agent-lightning/stable/30-controller-configuration/) | Local and Kubernetes runners |
| [Asynchronous Training](https://microsoft.github.io/agent-lightning/stable/35-asynchronous-training/) | Collocated async collection and pause/drain |
## ⚡ Examples
| Example | Description |
|---|---|
| [Calc-X](https://microsoft.github.io/agent-lightning/stable/50-example-calc-x/) | POC math reasoning example with AutoGen and MCP calculator tools, requiring only one GPU. |
| [GSM8K](https://microsoft.github.io/agent-lightning/stable/55-example-gsm8k/) | POC grade-school math reasoning example. |
| [ScienceWorld](https://microsoft.github.io/agent-lightning/stable/60-example-science-world/) | Interactive science tasks in a text-based environment. |
| [Search-R1](https://microsoft.github.io/agent-lightning/stable/65-example-search-r1/) | Multi-turn retrieval and reasoning agent. |
| [LLM-in-Sandbox](https://microsoft.github.io/agent-lightning/stable/70-example-llm-in-sandbox/) | General agent with computer and code execution tools. |
| [Coding Agent](https://microsoft.github.io/agent-lightning/stable/75-example-coding-agent/) | Coding agent trained with repository tests. |
## ⚡ Articles
- 8/19/2026 [Agent Lightning v1.0: Towards Harnessed Agentic RL](https://arxiv.org/abs/2608.17528) technical report.
- 12/17/2025 [Adopting the Trajectory Level Aggregation for Faster Training](https://agent-lightning.github.io/posts/trajectory_level_aggregation/) Agent-lightning blog.
- 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.
@@ -59,32 +92,23 @@ To start using Agent-lightning, check out our [documentation](https://microsoft.
- [AgentFlow](https://agentflow.stanford.edu/) — A modular multi-agent framework that combines planner, executor, verifier, and generator agents with the Flow-GRPO algorithm to tackle long-horizon, sparse-reward tasks.
- [Youtu-Agent](https://github.com/TencentCloudADP/Youtu-agent) — Youtu-Agent lets you build and train your agent with ease. Built with [a modified branch](https://github.com/microsoft/agent-lightning/tree/contrib/youtu-agent-lightning) of Agent Lightning, Youtu-Agent has verified up to 128 GPUs RL training on maths/code and search capabilities with steady convergence. Also check [the recipe](https://github.com/TencentCloudADP/youtu-agent/tree/rl/agl) and their blog [*Stop Wrestling with Your Agent RL: How Youtu-Agent Achieved Stable, 128-GPU Scaling Without Breaking a Sweat*](https://spotted-coconut-df8.notion.site/Stop-Wrestling-with-Your-Agent-RL-How-Youtu-Agent-Achieved-Stable-128-GPU-Scaling-Without-Breaking-2ca5e8f089ba80539a98c582b65e0233).
## ⚡ Architecture
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.
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.
<p align="center">
<img src="docs/assets/readme-architecture.svg" alt="Agent-lightning Architecture" style="width:100%"/>
</p>
## ⚡ CI Status
| Workflow | Status |
|----------|--------|
| CPU Tests | [![tests workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/tests.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/tests.yml) |
| Full Tests | [![tests summary workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-unit.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/badge-unit.yml) |
| UI Tests | [![UI Tests](https://github.com/microsoft/agent-lightning/actions/workflows/dashboard.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/dashboard.yml) |
| Examples Integration | [![examples summary workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-examples.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/badge-examples.yml) |
| Latest Dependency Compatibility | [![latest summary workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-latest.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/badge-latest.yml) |
| Legacy Examples Compatibility | [![compat summary workflow status](https://github.com/microsoft/agent-lightning/actions/workflows/badge-compat.yml/badge.svg)](https://github.com/microsoft/agent-lightning/actions/workflows/badge-compat.yml) |
## ⚡ Citation
If you find Agent Lightning useful in your research or projects, please cite our paper:
If you use Agent Lightning v1.0 in your research or projects, please cite the technical report:
```bibtex
@misc{he2026agentlightningv10harnessed,
title={Agent Lightning v1.0: Towards Harnessed Agentic RL},
author={Zhiyuan He and Siwei Zhang and Zhiwen Zhou and Yuqing Yang and Yu Kang and Yuge Zhang and Luna K. Qiu and Tin Yan Tsui and Jiahang Xu and Chong Luo},
year={2026},
eprint={2608.17528},
archivePrefix={arXiv},
primaryClass={cs.AI},
url={https://arxiv.org/abs/2608.17528},
}
```
For the original Agent Lightning paper, please use:
```bibtex
@misc{luo2025agentlightningtrainai,
@@ -114,6 +138,7 @@ This project may contain trademarks or logos for projects, products, or services
This project has been evaluated and certified to comply with the Microsoft Responsible AI Standard. The team will continue to monitor and maintain the repository, addressing any severe issues, including potential harms, if they arise.
## ⚡ License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
Agent Lightning v1.0 is released under the [MIT License](LICENSE).
+2 -19
View File
@@ -1,22 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
__version__ = "0.3.0"
"""Agent Lightning."""
from .adapter import *
from .algorithm import *
from .client import AgentLightningClient, DevTaskLoader # deprecated # type: ignore
from .config import *
from .emitter import *
from .env_var import *
from .execution import *
from .litagent import *
from .llm_proxy 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 *
from .tracer import *
from .trainer import *
from .types import *
__version__ = "1.0.0"
-15
View File
@@ -1,15 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from .base import Adapter, OtelTraceAdapter, TraceAdapter
from .messages import TraceToMessages
from .triplet import LlmProxyTraceToTriplet, TracerTraceToTriplet, TraceToTripletBase
__all__ = [
"TraceAdapter",
"OtelTraceAdapter",
"Adapter",
"TraceToTripletBase",
"TracerTraceToTriplet",
"LlmProxyTraceToTriplet",
"TraceToMessages",
]
-94
View File
@@ -1,94 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Generic, Sequence, TypeVar
from opentelemetry.sdk.trace import ReadableSpan
from agentlightning.types import Span
T_from = TypeVar("T_from")
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.
The class defines a minimal protocol so that adapters can be treated like callables while
still allowing subclasses to supply the concrete transformation logic.
!!! note
Subclasses must override [`adapt()`][agentlightning.Adapter.adapt] to provide
the actual conversion.
Type Variables:
T_from: Source data type supplied to the adapter.
T_to: Target data type produced by the adapter.
Examples:
>>> class IntToStrAdapter(Adapter[int, str]):
... def adapt(self, source: int) -> str:
... return str(source)
...
>>> adapter = IntToStrAdapter()
>>> adapter(42)
'42'
"""
def __call__(self, source: T_from, /) -> T_to:
"""Convert the data to the target format.
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.
Returns:
Data converted to the target format.
"""
return self.adapt(source)
def adapt(self, source: T_from, /) -> T_to:
"""Convert the data to the target format.
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.
Returns:
Data converted to the target format.
"""
raise NotImplementedError("Adapter.adapt() is not implemented")
class OtelTraceAdapter(Adapter[Sequence[ReadableSpan], T_to], Generic[T_to]):
"""Base class for adapters that convert OpenTelemetry trace spans into other formats.
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.
Examples:
>>> class TraceToDictAdapter(OtelTraceAdapter[dict]):
... def adapt(self, spans: List[ReadableSpan]) -> dict:
... return {"count": len(spans)}
...
>>> adapter = TraceToDictAdapter()
>>> adapter([span1, span2])
{'count': 2}
"""
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`][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.
"""
-270
View File
@@ -1,270 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
from collections import defaultdict
from typing import TYPE_CHECKING, Any, Dict, Generator, Iterable, List, Optional, Sequence, TypedDict, Union, cast
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]
response: Dict[str, Any]
tools: List[Dict[str, Any]]
def group_genai_dict(data: Dict[str, Any], prefix: str) -> Union[Dict[str, Any], List[Any]]:
"""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 whose keys are dotted paths.
prefix: Top-level key (for example `gen_ai.prompt`) that determines which attributes are
grouped.
Returns:
A nested dictionary (no numeric index detected) or list (numeric indices detected) containing
the grouped values.
"""
result: Union[Dict[str, Any], List[Any]] = {}
# Collect keys that match the prefix
relevant = {k[len(prefix) + 1 :]: v for k, v in data.items() if k.startswith(prefix + ".")}
# Detect if we have numeric indices (-> list) or not (-> dict)
indexed = any(part.split(".")[0].isdigit() for part in relevant.keys())
if indexed:
# Group by index
grouped: Dict[int, Dict[str, Any]] = defaultdict(dict)
for k, v in relevant.items():
parts = k.split(".")
if not parts[0].isdigit():
continue
idx, rest = int(parts[0]), ".".join(parts[1:])
grouped[idx][rest] = v
# Recursively build
result = []
for i in sorted(grouped.keys()):
result.append(group_genai_dict({f"{prefix}.{rest}": val for rest, val in grouped[i].items()}, prefix))
else:
# No indices: build dict
nested: Dict[str, Any] = defaultdict(dict)
for k, v in relevant.items():
if "." in k:
head, _tail = k.split(".", 1)
nested[head][f"{prefix}.{k}"] = v
else:
result[k] = v
# Recurse into nested dicts
for head, subdict in nested.items():
result[head] = group_genai_dict(subdict, prefix + "." + head)
return result
def convert_to_openai_messages(prompt_completion_list: List[_RawSpanInfo]) -> Generator[OpenAIMessages, None, None]:
"""Convert raw trace payloads into OpenAI-style chat messages.
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] = []
# Extract messages
for msg in pc_entry["prompt"]:
role = msg["role"]
if role == "assistant" and "tool_calls" in msg:
# Use the tool_calls directly
# This branch is usually not used in the wild.
tool_calls: List[ChatCompletionMessageFunctionToolCallParam] = [
ChatCompletionMessageFunctionToolCallParam(
id=call["id"],
type="function",
function={"name": call["name"], "arguments": call["arguments"]},
)
for call in msg["tool_calls"]
]
messages.append(
ChatCompletionAssistantMessageParam(role="assistant", content=None, tool_calls=tool_calls)
)
else:
# Normal user/system/tool content
message = cast(
ChatCompletionMessageParam,
TypeAdapter(ChatCompletionMessageParam).validate_python(
dict(role=role, content=msg.get("content", ""), tool_call_id=msg.get("tool_call_id", None))
),
)
messages.append(message)
# Extract completions (assistant outputs after tool responses)
for comp in pc_entry["completion"]:
if comp.get("role") == "assistant":
content = comp.get("content")
if pc_entry["tools"]:
tool_calls = [
ChatCompletionMessageFunctionToolCallParam(
id=tool["call"]["id"],
type=tool["call"]["type"],
function={"name": tool["name"], "arguments": tool["parameters"]},
)
for tool in pc_entry["tools"]
]
messages.append(
ChatCompletionAssistantMessageParam(role="assistant", content=content, tool_calls=tool_calls)
)
else:
messages.append(ChatCompletionAssistantMessageParam(role="assistant", content=content))
# Build tools definitions (if available)
if "functions" in pc_entry["request"]:
tools = [
ChatCompletionFunctionToolParam(
type="function",
function={
"name": fn["name"],
"description": fn.get("description", ""),
"parameters": (
json.loads(fn["parameters"]) if isinstance(fn["parameters"], str) else fn["parameters"]
),
},
)
for fn in pc_entry["request"]["functions"]
]
yield OpenAIMessages(messages=messages, tools=tools)
else:
yield OpenAIMessages(messages=messages, tools=None)
class TraceToMessages(TraceAdapter[List[OpenAIMessages]]):
"""Convert trace spans into OpenAI-compatible conversation messages.
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.
!!! 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: Sequence[Span], /) -> Iterable[Dict[str, Any]]:
"""Yield tool call payloads for a completion span.
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]
# Get the tool calls from the children
for maybe_tool_call in children:
tool_call = group_genai_dict(maybe_tool_call.attributes, "tool")
if not isinstance(tool_call, dict):
raise ValueError(f"Extracted tool call from trace is not a dict: {tool_call}")
if tool_call:
yield tool_call
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:
attributes = {k: v for k, v in span.attributes.items()}
# Get all related information from the trace span
prompt = group_genai_dict(attributes, "gen_ai.prompt") or []
completion = group_genai_dict(attributes, "gen_ai.completion") or []
request = group_genai_dict(attributes, "gen_ai.request") or {}
response = group_genai_dict(attributes, "gen_ai.response") or {}
if not isinstance(prompt, list):
raise ValueError(f"Extracted prompt from trace is not a list: {prompt}")
if not isinstance(completion, list):
raise ValueError(f"Extracted completion from trace is not a list: {completion}")
if not isinstance(request, dict):
raise ValueError(f"Extracted request from trace is not a dict: {request}")
if not isinstance(response, dict):
raise ValueError(f"Extracted response from trace is not a dict: {response}")
if prompt or completion or request or response:
tools = list(self.get_tool_calls(span, source)) or []
raw_prompt_completions.append(
_RawSpanInfo(
prompt=prompt or [], completion=completion, request=request, response=response, tools=tools
)
)
return list(convert_to_openai_messages(raw_prompt_completions))
File diff suppressed because it is too large Load Diff
-29
View File
@@ -1,29 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from .base import Algorithm
from .decorator import algo
from .fast import Baseline, FastAlgorithm
if TYPE_CHECKING:
from .apo import APO as APOType
from .verl import VERL as VERLType
__all__ = ["Algorithm", "algo", "FastAlgorithm", "Baseline", "APO", "VERL"]
# Shortcuts for usages like algo.APO(...)
def APO(*args: Any, **kwargs: Any) -> APOType[Any]:
from .apo import APO as APOImplementation
return APOImplementation(*args, **kwargs)
def VERL(*args: Any, **kwargs: Any) -> VERLType:
from .verl import VERL as VERLImplementation
return VERLImplementation(*args, **kwargs)
-5
View File
@@ -1,5 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from .apo import APO
__all__ = ["APO"]
-889
View File
@@ -1,889 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""
APO with textual gradients that read rollout spans and outputs to modify the prompt.
- algo: beam search with span-aware textual gradients -> apply_edit via LLM
- rollout: same pattern as your example, but task is a dict (T_task)
"""
from __future__ import annotations
import asyncio
import logging
import random
import time
from dataclasses import dataclass
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Counter,
Dict,
Generic,
Iterator,
List,
Optional,
Sequence,
Set,
Tuple,
TypedDict,
TypeVar,
cast,
)
import poml
from openai import AsyncOpenAI
from agentlightning.adapter.messages import TraceToMessages
from agentlightning.algorithm.base import Algorithm
from agentlightning.algorithm.utils import batch_iter_over_dataset, with_llm_proxy, with_store
from agentlightning.reward import find_final_reward
from agentlightning.types import Dataset, NamedResources, PromptTemplate, Rollout, RolloutMode, RolloutStatus
if TYPE_CHECKING:
from agentlightning.llm_proxy import LLMProxy
from agentlightning.store.base import LightningStore
logger = logging.getLogger(__name__)
T_task = TypeVar("T_task")
class RolloutResultForAPO(TypedDict):
"""This must be all JSON serializable to be processable by POML."""
status: RolloutStatus
final_reward: Optional[float]
spans: List[Dict[str, Any]]
messages: List[Any]
@dataclass
class VersionedPromptTemplate:
version: str
prompt_template: PromptTemplate
score: Optional[float] = None
GRADIENT_PROMPT_FILES = [
Path(__file__).parent / "prompts" / "text_gradient_variant01.poml",
Path(__file__).parent / "prompts" / "text_gradient_variant02.poml",
Path(__file__).parent / "prompts" / "text_gradient_variant03.poml",
]
APPLY_EDIT_PROMPT_FILES = [
Path(__file__).parent / "prompts" / "apply_edit_variant01.poml",
Path(__file__).parent / "prompts" / "apply_edit_variant02.poml",
]
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
to improve prompts through a beam search process. It evaluates prompts on rollouts,
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)
"""
def __init__(
self,
async_openai_client: AsyncOpenAI,
*,
gradient_model: str = "gpt-5-mini",
apply_edit_model: str = "gpt-4.1-mini",
diversity_temperature: float = 1.0,
gradient_batch_size: int = 4,
val_batch_size: int = 16,
beam_width: int = 4,
branch_factor: int = 4,
beam_rounds: int = 3,
rollout_batch_timeout: float = 3600.0,
run_initial_validation: bool = True,
# Internal flags for debugging
_poml_trace: bool = False,
):
"""
Initialize the APO algorithm with configuration parameters.
Args:
async_openai_client: AsyncOpenAI client for making LLM API calls.
gradient_model: Model name for computing textual gradients (critiques).
apply_edit_model: Model name for applying edits based on critiques.
diversity_temperature: Temperature parameter for LLM calls to control diversity.
gradient_batch_size: Number of rollout results to sample for gradient computation.
val_batch_size: Number of validation examples to use for evaluation.
beam_width: Number of top-scoring prompts to keep in the beam at each round.
branch_factor: Number of new prompt candidates to generate from each parent prompt
by applying textual gradient edits. This controls the expansion of the search tree.
beam_rounds: Number of beam search rounds to perform.
rollout_batch_timeout: Maximum time in seconds to wait for rollout batch completion.
run_initial_validation: If True, runs validation on the seed prompt before starting
optimization to establish a baseline score. Defaults to True.
"""
self.async_openai_client = async_openai_client
self.gradient_model = gradient_model
self.apply_edit_model = apply_edit_model
self.diversity_temperature = diversity_temperature
self.gradient_batch_size = gradient_batch_size
self.val_batch_size = val_batch_size
self.beam_width = beam_width
self.branch_factor = branch_factor
self.beam_rounds = beam_rounds
self.rollout_batch_timeout = rollout_batch_timeout
self.run_initial_validation = run_initial_validation
self._history_best_prompt: Optional[PromptTemplate] = None
self._history_best_score: float = float("-inf")
self._history_best_version: Optional[str] = None
self._version_counter: int = 0
self._poml_trace = _poml_trace
def _create_versioned_prompt(
self,
prompt_template: PromptTemplate,
*,
score: Optional[float] = None,
) -> VersionedPromptTemplate:
"""
Wrap a prompt template with a new monotonically increasing version identifier.
"""
version = f"v{self._version_counter}"
self._version_counter += 1
return VersionedPromptTemplate(version=version, prompt_template=prompt_template, score=score)
def _format_log_prefix(
self,
*,
round_num: Optional[int] = None,
beam_idx: Optional[int] = None,
branch_idx: Optional[int] = None,
prompt_version: Optional[str] = None,
) -> str:
"""
Construct the standardized log prefix.
"""
parts: List[str] = []
if round_num is not None:
parts.append(f"Round {round_num:02d}")
if beam_idx is not None:
parts.append(f"Beam {beam_idx:02d}")
if branch_idx is not None:
parts.append(f"Branch {branch_idx:02d}")
if prompt_version is not None:
parts.append(f"Prompt {prompt_version}")
if not parts:
return ""
return f"[{' | '.join(parts)}]"
def _log(self, level: int, message: str, *, prefix: Optional[str] = None) -> None:
"""
Log a message with an optional standardized prefix.
"""
effective_prefix = prefix
if effective_prefix:
logger.log(level, f"{effective_prefix} {message}")
else:
logger.log(level, message)
def get_seed_prompt_template(self) -> Tuple[str, PromptTemplate]:
"""
Extract the initial prompt template from the algorithm's resources.
Returns:
A tuple of (resource_name, prompt_template) representing the seed prompt.
Raises:
ValueError: If initial_resources is not set or no PromptTemplate is found.
"""
initial_resources = self.get_initial_resources()
if initial_resources is None:
raise ValueError(
"initial_resources are not set for APO algorithm. "
"Use algorithm.set_initial_resources() to set initial resources or set it in Trainer()"
)
for name, resource in initial_resources.items():
if isinstance(resource, PromptTemplate):
return name, resource
raise ValueError("No prompt template resource found in initial_resources")
def get_adapter(self) -> TraceToMessages:
"""
Get the adapter for converting spans to messages.
Returns:
The TraceToMessages instance for this algorithm.
Raises:
ValueError: If the adapter is not a TraceToMessages.
"""
adapter = super().get_adapter()
if not isinstance(adapter, TraceToMessages):
raise ValueError("Adapter must be a TraceToMessages for APO algorithm")
return adapter
def get_best_prompt(self) -> PromptTemplate:
"""
Retrieve the best prompt discovered during optimization.
Returns:
The prompt template with the highest validation score found so far.
Raises:
ValueError: If no best prompt has been found yet (run() not called).
"""
if self._history_best_prompt is None:
raise ValueError("No best prompt found")
return self._history_best_prompt
async def compute_textual_gradient(
self,
current_prompt: VersionedPromptTemplate,
rollout_results: List[RolloutResultForAPO],
*,
prefix: Optional[str] = None,
) -> Optional[str]:
"""
Compute a textual gradient (critique) for the current prompt based on rollout results.
This method samples rollout results, sends them to an LLM along with the current prompt,
and generates a critique describing how the prompt could be improved.
Args:
current_prompt: The prompt template to critique.
rollout_results: List of rollout results containing spans, messages, and rewards.
Returns:
A textual critique generated by the LLM, or None if generation fails.
"""
tg_template = random.choice(GRADIENT_PROMPT_FILES)
if len(rollout_results) < self.gradient_batch_size:
self._log(
logging.WARNING,
f"Only {len(rollout_results)} rollouts available, but {self.gradient_batch_size} are needed. Using all rollouts.",
prefix=prefix,
)
sampled_rollout_results = rollout_results
else:
sampled_rollout_results = random.sample(rollout_results, self.gradient_batch_size)
self._log(
logging.INFO,
f"Gradient will be computed with {self.gradient_model} for {len(sampled_rollout_results)} rollouts with template: {tg_template.name}",
prefix=prefix,
)
tg_msg = poml.poml( # type: ignore
tg_template,
context={
"experiments": sampled_rollout_results,
"prompt_template": current_prompt.prompt_template.template,
},
format="openai_chat",
)
self._log(
logging.DEBUG,
f"Gradient computed with {self.gradient_model} prompt: {tg_msg}",
prefix=prefix,
)
critique_response = await self.async_openai_client.chat.completions.create(
model=self.gradient_model,
messages=tg_msg["messages"], # type: ignore
temperature=self.diversity_temperature,
)
critique_text = critique_response.choices[0].message.content
self._log(
logging.INFO,
f"Gradient computed with {self.gradient_model} has result: {critique_text}",
prefix=prefix,
)
return critique_text
async def textual_gradient_and_apply_edit(
self,
current_prompt: VersionedPromptTemplate,
rollout: List[RolloutResultForAPO],
*,
prefix: Optional[str] = None,
) -> Optional[str]:
"""
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
Args:
current_prompt: The current prompt template to improve.
rollout: List of rollout results to base the critique on.
Returns:
The improved prompt text, or the original prompt if gradient computation fails.
"""
# 1) Critique
critique_text = await self.compute_textual_gradient(
current_prompt,
rollout,
prefix=prefix,
)
if not critique_text:
self._log(
logging.ERROR,
"Failed to compute critique for prompt.",
prefix=prefix,
)
return current_prompt.prompt_template.template
# 2) Apply edit
ae_template = random.choice(APPLY_EDIT_PROMPT_FILES)
self._log(
logging.INFO,
f"Edit will be generated by {self.apply_edit_model} with template: {ae_template.name}",
prefix=prefix,
)
ae_msg = poml.poml( # type: ignore
ae_template,
context={
"prompt_template": current_prompt.prompt_template.template,
"critique": critique_text,
},
format="openai_chat",
)
ae_response = await self.async_openai_client.chat.completions.create(
model=self.apply_edit_model,
messages=ae_msg["messages"], # type: ignore
temperature=self.diversity_temperature,
)
new_prompt = ae_response.choices[0].message.content
if new_prompt:
self._log(
logging.INFO,
f"Edit generated by {self.apply_edit_model}: {new_prompt[:50]}...",
prefix=prefix,
)
return new_prompt
@with_store
async def get_rollout_results(
self,
store: LightningStore,
rollout: List[Rollout],
*,
prefix: Optional[str] = None,
) -> List[RolloutResultForAPO]:
"""
Convert completed rollouts to APO-compatible result format.
Fetches spans for each rollout, adapts them to messages, and packages them
with rewards and status information for gradient computation.
Args:
rollout: List of completed rollout metadata.
Returns:
List of rollout results formatted for APO processing.
"""
rollout_results: List[RolloutResultForAPO] = []
adapter = self.get_adapter()
for r in rollout:
spans = await store.query_spans(r.rollout_id)
messages = adapter.adapt(spans)
rollout_result = RolloutResultForAPO(
status=r.status,
final_reward=find_final_reward(spans),
spans=[span.model_dump() for span in spans],
messages=messages,
)
self._log(
logging.DEBUG,
f"Rollout result for {r.rollout_id}: status {rollout_result['status']} with final reward {rollout_result['final_reward']}. "
f"{len(rollout_result['spans'])} spans and {len(rollout_result['messages'])} messages.",
prefix=prefix,
)
rollout_results.append(rollout_result)
return rollout_results
async def evaluate_prompt_on_batch(
self,
prompt: VersionedPromptTemplate,
resource_name: str,
dataset: Sequence[T_task],
mode: RolloutMode,
*,
prefix: Optional[str] = None,
) -> Tuple[List[RolloutResultForAPO], float]:
"""
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)
4. Computes and returns the average reward
Args:
prompt: The prompt template string to evaluate.
resource_name: The name to register the prompt under in the store.
dataset: Sequence of tasks to evaluate the prompt on.
mode: Rollout mode ("train" or "val") for logging/tracking.
Returns:
A tuple of (rollout_results, average_reward) where rollout_results contains
detailed information for each rollout and average_reward is the mean final reward.
"""
store = self.get_store()
preview = prompt.prompt_template.template[:50]
self._log(
logging.INFO,
f'Evaluating prompt "{preview}..." on {len(dataset)} tasks in {mode} mode',
prefix=prefix,
)
# Install prompt as named resource
resources: NamedResources = {resource_name: prompt.prompt_template}
resource_update = await store.update_resources(prompt.version, resources)
rollout_ids: List[str] = []
for t in dataset:
r = await store.enqueue_rollout(input=t, mode=mode, resources_id=resource_update.resources_id)
rollout_ids.append(r.rollout_id)
deadline = time.time() + self.rollout_batch_timeout
finished: List[Rollout] = []
while time.time() < deadline:
finished = await store.wait_for_rollouts(rollout_ids=rollout_ids, timeout=0.0)
if len(finished) >= len(rollout_ids):
self._log(
logging.INFO,
f"All {len(rollout_ids)} rollouts finished within timeout.",
prefix=prefix,
)
break
else:
self._log(
logging.DEBUG,
f"Only {len(finished)} rollouts finished within timeout. Waiting for remaining {len(rollout_ids) - len(finished)} rollouts.",
prefix=prefix,
)
# Sleep to avoid busy-waiting
await asyncio.sleep(2.0)
rollout_results = await self.get_rollout_results(
finished,
prefix=prefix,
)
final_rewards = [rr["final_reward"] for rr in rollout_results]
avg = float(sum([r or 0.0 for r in final_rewards]) / max(1, len(final_rewards)))
status_counter = Counter([rr["status"] for rr in rollout_results])
self._log(
logging.INFO,
f"Evaluated {len(rollout_results)} rollouts. Statuses: {status_counter}. Rewards: {final_rewards}, average is {avg}",
prefix=prefix,
)
return rollout_results, avg
def _initialize_beam(
self,
train_dataset: Optional[Dataset[T_task]],
val_dataset: Optional[Dataset[T_task]],
) -> Tuple[str, PromptTemplate, Iterator[Sequence[T_task]], Iterator[Sequence[T_task]]]:
"""
Initialize the beam search with seed prompt and dataset iterators.
Args:
train_dataset: Dataset for computing gradients.
val_dataset: Dataset for evaluating prompts.
Returns:
Tuple of (resource_name, seed_prompt, grad_iterator, val_iterator).
Raises:
ValueError: If either dataset is None.
"""
resource_name, seed_prompt = self.get_seed_prompt_template()
if train_dataset is None:
raise ValueError("train_dataset is required for APO algorithm")
if val_dataset is None:
raise ValueError("val_dataset is required for APO algorithm")
grad_dataset_iterator = batch_iter_over_dataset(train_dataset, self.gradient_batch_size)
val_dataset_iterator = batch_iter_over_dataset(val_dataset, self.val_batch_size)
# Initialize history tracking
self._history_best_prompt = seed_prompt
self._history_best_score = float("-inf")
return resource_name, seed_prompt, grad_dataset_iterator, val_dataset_iterator
def _sample_parent_prompts(
self,
beam: List[VersionedPromptTemplate],
round_num: int,
) -> List[Tuple[int, VersionedPromptTemplate]]:
"""
Sample parent prompts from the current beam for generating new candidates.
If the beam has fewer prompts than beam_width, replicates existing prompts.
Otherwise, randomly samples beam_width prompts.
Args:
beam: Current list of prompt templates in the beam.
round_num: Current round number (for logging, 0-indexed).
Returns:
List of parent prompts to generate children from.
"""
display_round = round_num + 1
if len(beam) < self.beam_width:
prefix = self._format_log_prefix(round_num=display_round)
self._log(
logging.WARNING,
f"Beam width is currently {self.beam_width}, but only {len(beam)} prompts in beam. Replicating all prompts.",
prefix=prefix,
)
return [(i % len(beam), beam[i % len(beam)]) for i in range(self.beam_width)]
selected_indices = random.sample(range(len(beam)), self.beam_width)
return [(idx, beam[idx]) for idx in selected_indices]
async def _generate_candidate_prompts(
self,
parent_prompts: List[Tuple[int, VersionedPromptTemplate]],
resource_name: str,
grad_dataset_iterator: Iterator[Sequence[T_task]],
round_num: int,
) -> List[VersionedPromptTemplate]:
"""
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
Args:
parent_prompts: List of parent prompts to generate children from.
resource_name: Name to register prompts under in the store.
grad_dataset_iterator: Iterator over training data batches.
round_num: Current round number (for logging, 0-indexed).
Returns:
List of newly generated prompt templates.
"""
display_round = round_num + 1
round_prefix = self._format_log_prefix(round_num=display_round)
self._log(
logging.INFO,
f"Applying {self.branch_factor} edits to each of the {len(parent_prompts)} parents based on "
"gradients computed on training dataset",
prefix=round_prefix,
)
parent_prompts_str = [
f"{p.version}:{p.score:.3f}" if p.score is not None else p.version for _, p in parent_prompts
]
self._log(
logging.INFO,
f"Parent prompts: {', '.join(parent_prompts_str)}",
prefix=round_prefix,
)
candidates: List[VersionedPromptTemplate] = []
used_beam_indices: Set[int] = set()
for real_beam_idx, (beam_idx, prompt) in enumerate(parent_prompts):
if beam_idx in used_beam_indices:
beam_prefix = self._format_log_prefix(
round_num=display_round,
beam_idx=beam_idx + 1,
prompt_version=prompt.version,
)
self._log(
logging.WARNING,
"Duplicated beam index found. Might be caused by beam_width too high. "
+ f"The real index of this beam is {real_beam_idx + 1}.",
prefix=beam_prefix,
)
else:
used_beam_indices.add(beam_idx)
for branch_idx in range(self.branch_factor):
parent_prefix = self._format_log_prefix(
round_num=display_round,
beam_idx=beam_idx + 1,
branch_idx=branch_idx + 1,
prompt_version=prompt.version,
)
baseline_score = f"{prompt.score:.3f}" if prompt.score is not None else "N/A"
self._log(
logging.INFO,
f"Use parent prompt {prompt.version} as a baseline to generate a new prompt. Baseline score: {baseline_score}",
prefix=parent_prefix,
)
grad_samples = next(grad_dataset_iterator)
rollout_results, _ = await self.evaluate_prompt_on_batch(
prompt,
resource_name,
grad_samples,
mode="train",
prefix=parent_prefix,
)
new_prompt = await self.textual_gradient_and_apply_edit(
prompt,
rollout_results,
prefix=parent_prefix,
)
if not new_prompt:
self._log(
logging.ERROR,
f"Failed to compute edit for prompt: {prompt.prompt_template.template}",
prefix=parent_prefix,
)
continue
new_prompt_template = PromptTemplate(template=new_prompt, engine="f-string")
versioned_candidate = self._create_versioned_prompt(new_prompt_template)
self._log(
logging.INFO,
f"New prompt template created from parent {prompt.version}: {versioned_candidate.version}",
prefix=parent_prefix,
)
candidate_prefix = self._format_log_prefix(
round_num=display_round, prompt_version=versioned_candidate.version
)
self._log(
logging.INFO,
f"New prompt template created from parent {prompt.version}:\n```\n{new_prompt}\n```",
prefix=candidate_prefix,
)
candidates.append(versioned_candidate)
return candidates
async def _evaluate_and_select_beam(
self,
candidates: List[VersionedPromptTemplate],
resource_name: str,
val_dataset_iterator: Iterator[Sequence[T_task]],
round_num: int,
) -> List[VersionedPromptTemplate]:
"""
Evaluate all candidate prompts on validation data and select top-k for the beam.
Args:
candidates: List of candidate prompts to evaluate.
resource_name: Name to register prompts under in the store.
val_dataset_iterator: Iterator over validation data batches.
round_num: Current round number (for logging, 0-indexed).
Returns:
List of top beam_width prompts sorted by validation score (best first).
Raises:
ValueError: If no candidates remain after evaluation.
"""
display_round = round_num + 1
round_prefix = self._format_log_prefix(round_num=display_round)
self._log(
logging.INFO,
f"Evaluating {len(candidates)} candidates on validation dataset",
prefix=round_prefix,
)
val_batch = next(val_dataset_iterator)
for prompt in candidates:
candidate_prefix = self._format_log_prefix(
round_num=display_round,
prompt_version=prompt.version,
)
_, score = await self.evaluate_prompt_on_batch(
prompt,
resource_name,
val_batch,
mode="val",
prefix=candidate_prefix,
)
prompt.score = score
self._log(
logging.INFO,
f"Candidate score: {score:.3f}",
prefix=candidate_prefix,
)
# Sort by score (descending) and select top beam_width
sorted_prompts = [p for p in sorted(candidates, key=lambda x: cast(float, x.score), reverse=True)]
selected_prompts = sorted_prompts[: self.beam_width]
selected_versions = [
f"{prompt.version}:{prompt.score:.3f}" if prompt.score is not None else prompt.version
for prompt in selected_prompts
]
self._log(
logging.INFO,
f"Top {len(selected_prompts)} candidates on validation dataset: {selected_versions}",
prefix=round_prefix,
)
if len(selected_prompts) == 0:
raise ValueError("No beam candidates any more")
return selected_prompts
async def _update_best_prompt(
self,
beam: List[VersionedPromptTemplate],
resource_name: str,
val_dataset: Dataset[T_task],
round_num: int,
) -> None:
"""
Evaluate the best prompt in the beam on the full validation set and update history.
Args:
beam: Current beam of prompts (sorted, best first).
resource_name: Name to register prompts under in the store.
val_dataset: Full validation dataset.
round_num: Current round number (for logging, 0-indexed).
"""
display_round = round_num + 1
best_prompt = beam[0]
prefix = self._format_log_prefix(round_num=display_round, prompt_version=best_prompt.version)
_, best_score = await self.evaluate_prompt_on_batch(
best_prompt,
resource_name,
cast(Sequence[T_task], val_dataset),
mode="val",
prefix=prefix,
)
self._log(
logging.INFO,
f"Beam leader score: {best_score:.3f}",
prefix=prefix,
)
if best_score > self._history_best_score:
prev = self._history_best_score
self._log(
logging.INFO,
f"Best prompt updated. New best score: {best_score:.3f} (prev: {prev:.3f})",
prefix=prefix,
)
self._history_best_prompt = best_prompt.prompt_template
self._history_best_score = best_score
self._history_best_version = best_prompt.version
else:
self._log(
logging.WARNING,
f"Best prompt not updated. Current score: {best_score:.3f} vs. history best: {self._history_best_score:.3f})",
prefix=prefix,
)
@with_llm_proxy()
@with_store
async def run(
self,
store: LightningStore, # Injected by decorator - callers should not provide this parameter
llm_proxy: Optional[LLMProxy], # Injected by decorator - callers should not provide this parameter
train_dataset: Optional[Dataset[T_task]] = None,
val_dataset: Optional[Dataset[T_task]] = None,
) -> None:
"""
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
- Uses different training data samples for each gradient computation to ensure diversity
Args:
train_dataset: Dataset of tasks for computing textual gradients. Required.
val_dataset: Dataset of tasks for evaluating and selecting prompts. Required.
Raises:
ValueError: If train_dataset or val_dataset is None, or if resources are not set.
"""
# Initialize beam search
resource_name, seed_prompt, grad_iterator, val_iterator = self._initialize_beam(train_dataset, val_dataset)
if self._poml_trace:
poml.set_trace(trace_dir="pomltrace")
# Validation datasets are guaranteed to be non-None after initialization
assert val_dataset is not None
# Start with seed prompt in the beam
seed_versioned = self._create_versioned_prompt(seed_prompt)
beam: List[VersionedPromptTemplate] = [seed_versioned]
self._history_best_prompt = seed_prompt
self._history_best_version = seed_versioned.version
# Optionally evaluate seed prompt on validation set to establish baseline
if self.run_initial_validation:
seed_prefix = self._format_log_prefix(round_num=0, prompt_version=seed_versioned.version)
self._log(
logging.INFO,
"Evaluating seed prompt on validation dataset before optimization...",
prefix=seed_prefix,
)
_, seed_score = await self.evaluate_prompt_on_batch(
seed_versioned,
resource_name,
cast(Sequence[T_task], val_dataset),
mode="val",
prefix=seed_prefix,
)
self._log(
logging.INFO,
f"Seed prompt baseline score: {seed_score:.3f}",
prefix=seed_prefix,
)
self._history_best_prompt = seed_prompt
self._history_best_score = seed_score
self._history_best_version = seed_versioned.version
# Run beam search for specified number of rounds
for rnd in range(self.beam_rounds):
display_round = rnd + 1
round_prefix = self._format_log_prefix(round_num=display_round)
self._log(
logging.INFO,
f"Round {display_round}/{self.beam_rounds}...",
prefix=round_prefix,
)
# Sample parent prompts from current beam
parent_prompts = self._sample_parent_prompts(beam, rnd)
# Generate new candidate prompts from parents
new_candidates = await self._generate_candidate_prompts(parent_prompts, resource_name, grad_iterator, rnd)
# Combine existing beam with new candidates
all_candidates = [*beam, *new_candidates]
# Evaluate and select top-k prompts for next beam
beam = await self._evaluate_and_select_beam(all_candidates, resource_name, val_iterator, rnd)
# Update historically best prompt if improved
await self._update_best_prompt(beam, resource_name, val_dataset, rnd)
@@ -1,22 +0,0 @@
<poml>
<p>Revise the given prompt template using the critique as constraints and improvement guide.</p>
<cp caption="Revision Rules">
<list listStyle="decimal">
<item>Rewrite or restructure the prompt if critique implies it.</item>
<item>Explicitly include any requested output format, structure, or word limit, if requested by the critique.</item>
<item>Prioritize mechanism-first phrasing: define what to do, then how to do it.</item>
<item>Preserve placeholder variables inside curly brackets.</item>
</list>
</cp>
<output-format>
Return only the improved prompt template with placeholders intact. Do not include other explanations on how you did it, or headers and introductory texts.
</output-format>
<human-msg>
<cp caption="Prompt Template">
<text whiteSpace="pre">{{ prompt_template }}</text>
</cp>
<cp caption="Critique">
<text whiteSpace="pre">{{ critique }}</text>
</cp>
</human-msg>
</poml>
@@ -1,18 +0,0 @@
<!-- Conservative Edit Prompt -->
<poml>
<p>Revise the prompt to address ONE critique point clearly and effectively. Preserve all variable names in curly-brackets.</p>
<p>Do not address more than one critique point. Focus on the single most critical issue.</p>
<p>Keep the new prompt close in tone, length, and structure to the original.</p>
<output-format>
Return only the revised full prompt. Do not include explanations, comparisons, or other text.
</output-format>
<human-msg>
<cp caption="PROMPT" level="3">
<text whiteSpace="pre">{{ prompt_template }}</text>
</cp>
<cp caption="CRITIQUE" level="3">
<text whiteSpace="pre">{{ critique }}</text>
</cp>
</human-msg>
</poml>
@@ -1,18 +0,0 @@
<poml>
<p>You optimize a prompt template.</p>
<cp caption="Original Prompt Template">
<text whiteSpace="pre">{{ prompt_template }}</text>
</cp>
<cp caption="Experiments with Original Prompt Template">
<cp for="experiment in experiments" caption="Experiment {{ loop.index + 1 }}">
<p>This experiment has {{ experiment.status }}. It gets a final reward: {{ experiment.final_reward }}</p>
<cp caption="Rollout Traces (Chat Messages, Grader Requests included)">
<object data="{{ experiment.messages }}" />
</cp>
</cp>
</cp>
<cp caption="Your Task">
Produce a brief critique listing specific causes for the error or ways to raise reward next time.
Return a bullet list with concrete, testable changes (format, constraints, ordering, definitions).
</cp>
</poml>
@@ -1,16 +0,0 @@
<poml>
<role>You are a prompt engineer.</role>
<task>Analyze where the current prompt failed to elicit the right mechanism.</task>
<cp caption="Current Prompt Template">
<text whiteSpace="pre">{{ prompt_template }}</text>
</cp>
<cp caption="Sample Runs with Current Prompt Template">
<p>The following are the OpenTelemetry spans collected from the sample runs with the current prompt template. They should contain both prompt, responses and rewards.</p>
<cp for="experiment in experiments" caption="Sample Run #{{ loop.index + 1 }} Diagnostics">
<object for="span in experiment.spans" data="{{ span }}" />
</cp>
</cp>
<output-format>
Write 3-5 short bullets titled 'Critique:' focusing on missing constraints, ordering, or formatting.
</output-format>
</poml>
@@ -1,107 +0,0 @@
<poml>
<role>You are an expert prompt engineer.</role>
<task>Your task is to analyze the prompt and provide a critique of the prompt. Follow the steps below to create the critique.
<cp caption="1. Structural Issues">
<p>These flaws block clarity and logic. Always check them first.</p>
<list>
<item><b>Missing goal</b>: The prompt never defines what success looks like. Ask: <i>Can I summarize its output goal in one line?</i></item>
<item><b>Contradictions</b>: Two or more instructions conflict. Search for words like *never*, *always*, *except*, *but also*.</item>
<item><b>Circular dependencies</b>: The model is told to do A before B and B before A.</item>
<item><b>No stop condition</b>: The prompt doesnt say when the task is done. Flag any open-ended verbs: <i>explore,</i> <i>analyze further,</i> <i>continue indefinitely.</i></item>
</list>
</cp>
<cp caption="2. Instruction Quality">
<p>Examine how the instructions are stated and ordered to ensure clarity and enforceability.</p>
<list>
<item><b>Vague verbs</b>: Avoid terms like <i>optimize,</i> <i>improve,</i> and <i>ensure.</i> Use precise, measurable instructions.</item>
<item><b>Lack of hierarchy</b>: All rules appear equally important, making conflict resolution impossible. Clarify rule precedence.</item>
<item><b>Mixed abstraction</b>: High-level policies are interleaved with implementation details. Keep principles separate from step-by-step actions.</item>
<item><b>Overlapping scope</b>: Similar instructions appear in several sections with minor changes. Identify and consolidate duplicates.</item>
</list>
</cp>
<cp caption="3. Control and Behavior">
<p>Review boundaries on model autonomy, tool use, and communication style.</p>
<list>
<item><b>No tool limits</b>: Limits on tool calls, retries, or time not specified. Define boundaries for operations.</item>
<item><b>Unclear uncertainty handling</b>: Conflicting instructions regarding clarifying uncertainties vs. never asking users. Select one behavior.</item>
<item><b>Verbosity confusion</b>: Some parts demand detailed answers, others specify brevity. Highlight and resolve inconsistency.</item>
<item><b>Feedback omission</b>: No plan for progress reporting or preamble during multi-step operations.</item>
</list>
</cp>
<cp caption="4. Input and Output Specification">
<p>Assess if required data and expected output formats are clearly defined.</p>
<list>
<item><b>No input defaults</b>: What should happen if a needed value is absent or invalid isnt explained.</item>
<item><b>Output schema missing</b>: Expected response format or sections are not spelled out.</item>
<item><b>Format inconsistency</b>: Output style (Markdown, JSON, XML, etc.) shifts mid-prompt. Ensure format requirements are stable.</item>
<item><b>No validation</b>: Lacks steps like <i>verify results before submitting</i> or <i>summarize at end.</i></item>
</list>
</cp>
<cp caption="5. Scope and Safety">
<p>Ensure prompt actions remain within safe, authorized boundaries.</p>
<list>
<item><b>Scope creep</b>: Open-ended statements such as <i>feel free to enhance</i> can justify unrelated changes.</item>
<item><b>Unsafe actions</b>: Allows deletions or modifications without explicit user approval.</item>
<item><b>No error handling</b>: What happens if a tool call fails or data is missing is not addressed.</item>
<item><b>User authority ambiguity</b>: Model may act for multiple users or perform irreversible actions without checks.</item>
</list>
</cp>
<cp caption="6. Efficiency and Maintainability">
<p>Consider the prompts length, redundancy, and future comprehensibility.</p>
<list>
<item><b>Overexplained</b>: Verbose explanations where concise, numbered steps suffice.</item>
<item><b>Redundancy</b>: Similar rules scattered in multiple aliases; centralize and summarize them.</item>
<item><b>Hidden assumptions</b>: Implicit defaults (like timezone, language) are not stated.</item>
<item><b>Poor auditability</b>: Lacks section markers (e.g., <code>&lt;policy&gt;</code>, <code>&lt;procedure&gt;</code>). Structure prompt for easy review.</item>
</list>
</cp>
<cp caption="7. Testing Method">
<p>Methodical approach for reviewing a prompt:</p>
<list>
<item>Read the prompt fully; highlight all unclear or contradictory instructions.</item>
<item>For each main area, answer:
<list listStyle="decimal">
<item>What is the intended outcome?</item>
<item>What is the stop or completion condition?</item>
<item>How are conflicts between rules resolved?</item>
<item>What are the explicit limits (tools, run time, tokens)?</item>
<item>What should the output format be?</item>
</list>
</item>
<item>Rate each section: <i>clear</i>, <i>incomplete</i>, <i>contradictory</i>, or <i>redundant</i>.</item>
<item>Summarize findings under categories: structure, control, scope, format, safety.</item>
</list>
<p>This method surfaces issues such as ambiguity, contradiction, missing boundaries, and output uncertainty—core failure modes in prompting identified by the GPT-5 prompting guide.</p>
</cp>
</task>
<output-format>
Respond with a complete analysis and critique of the prompt. Be concise and direct. Less than 350 words.
</output-format>
<human-msg>
<cp caption="Prompt">
<text whiteSpace="pre">{{ prompt_template }}</text>
</cp>
<cp caption="Sample Runs of the Prompts (Historical Messages and Rewards)">
<cp for="experiment in experiments" caption="Sample Run #{{ loop.index + 1 }}">
<cp caption="Overall Status">
This run has {{ experiment.status }}. The final score is {{ experiment.final_reward }}.
</cp>
<cp caption="Messages">
<object data="{{ experiment.messages }}" />
</cp>
</cp>
</cp>
</human-msg>
</poml>
-162
View File
@@ -1,162 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import inspect
import weakref
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Optional,
Union,
)
from agentlightning.adapter import TraceAdapter
from agentlightning.client import AgentLightningClient
from agentlightning.store.base import LightningStore
from agentlightning.types import Dataset, NamedResources
if TYPE_CHECKING:
from agentlightning.llm_proxy import LLMProxy
from agentlightning.trainer import Trainer
class Algorithm:
"""Algorithm is the strategy, or tuner to train the agent."""
_trainer_ref: weakref.ReferenceType[Trainer] | None = None
_llm_proxy_ref: weakref.ReferenceType["LLMProxy"] | None = None
_store: LightningStore | None = None
_initial_resources: NamedResources | None = None
_adapter_ref: weakref.ReferenceType[TraceAdapter[Any]] | None = None
def is_async(self) -> bool:
"""Return True if the algorithm is asynchronous."""
return inspect.iscoroutinefunction(self.run)
def set_trainer(self, trainer: Trainer) -> None:
"""
Set the trainer for this algorithm.
Args:
trainer: The Trainer instance that will handle training and validation.
"""
self._trainer_ref = weakref.ref(trainer)
def get_trainer(self) -> Trainer:
"""
Get the trainer for this algorithm.
Returns:
The Trainer instance associated with this agent.
"""
if self._trainer_ref is None:
raise ValueError("Trainer has not been set for this agent.")
trainer = self._trainer_ref()
if trainer is None:
raise ValueError("Trainer reference is no longer valid (object has been garbage collected).")
return trainer
def set_llm_proxy(self, llm_proxy: LLMProxy | None) -> None:
"""
Set the LLM proxy for this algorithm to reuse when available.
Args:
llm_proxy: The LLMProxy instance configured by the trainer, if any.
"""
self._llm_proxy_ref = weakref.ref(llm_proxy) if llm_proxy is not None else None
def get_llm_proxy(self) -> Optional[LLMProxy]:
"""
Retrieve the configured LLM proxy instance, if one has been set.
Returns:
The active LLMProxy instance or None when not configured.
"""
if self._llm_proxy_ref is None:
return None
llm_proxy = self._llm_proxy_ref()
if llm_proxy is None:
raise ValueError("LLM proxy reference is no longer valid (object has been garbage collected).")
return llm_proxy
def set_adapter(self, adapter: TraceAdapter[Any]) -> None:
"""
Set the adapter for this algorithm to collect and convert traces.
"""
self._adapter_ref = weakref.ref(adapter)
def get_adapter(self) -> TraceAdapter[Any]:
"""
Retrieve the adapter for this algorithm to communicate with the runners.
"""
if self._adapter_ref is None:
raise ValueError("Adapter has not been set for this algorithm.")
adapter = self._adapter_ref()
if adapter is None:
raise ValueError("Adapter reference is no longer valid (object has been garbage collected).")
return adapter
def set_store(self, store: LightningStore) -> None:
"""
Set the store for this algorithm to communicate with the runners.
Store is set directly instead of using weakref because its copy is meant to be
maintained throughout the algorithm's lifecycle.
"""
self._store = store
def get_store(self) -> LightningStore:
"""
Retrieve the store for this algorithm to communicate with the runners.
"""
if self._store is None:
raise ValueError("Store has not been set for this algorithm.")
return self._store
def get_initial_resources(self) -> Optional[NamedResources]:
"""
Get the initial resources for this algorithm.
"""
return self._initial_resources
def set_initial_resources(self, resources: NamedResources) -> None:
"""
Set the initial resources for this algorithm.
"""
self._initial_resources = resources
def __call__(self, *args: Any, **kwargs: Any) -> Any:
return self.run(*args, **kwargs)
def run(
self,
train_dataset: Optional[Dataset[Any]] = None,
val_dataset: Optional[Dataset[Any]] = None,
) -> Union[None, Awaitable[None]]:
"""Subclasses should implement this method to implement the algorithm.
Args:
train_dataset: The dataset to train on. Not all algorithms require a training dataset.
val_dataset: The dataset to validate on. Not all algorithms require a validation dataset.
Returns:
Algorithm should refrain from returning anything. It should just run the algorithm.
"""
raise NotImplementedError("Subclasses must implement run().")
def get_client(self) -> AgentLightningClient:
"""Get the client to communicate with the algorithm.
If the algorithm does not require a server-client communication, it can also create a mock client
that never communicates with itself.
Deprecated and will be removed in a future version.
Returns:
The AgentLightningClient instance associated with this algorithm.
"""
raise NotImplementedError("Subclasses must implement get_client().")
-264
View File
@@ -1,264 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import functools
import inspect
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Dict,
Generic,
Literal,
Optional,
Protocol,
TypeVar,
Union,
cast,
overload,
)
from agentlightning.adapter import TraceAdapter
from agentlightning.store.base import LightningStore
from agentlightning.types import Dataset, NamedResources
if TYPE_CHECKING:
from agentlightning.llm_proxy import LLMProxy
from .base import Algorithm
# Algorithm function signature types
# We've missed a lot of combinations here.
# Let's add them in future.
class AlgorithmFuncSyncFull(Protocol):
def __call__(
self,
*,
store: LightningStore,
train_dataset: Optional[Dataset[Any]],
val_dataset: Optional[Dataset[Any]],
llm_proxy: Optional[LLMProxy],
adapter: Optional[TraceAdapter[Any]],
initial_resources: Optional[NamedResources],
) -> None: ...
class AlgorithmFuncSyncOnlyStore(Protocol):
def __call__(self, *, store: LightningStore) -> None: ...
class AlgorithmFuncSyncOnlyDataset(Protocol):
def __call__(self, *, train_dataset: Optional[Dataset[Any]], val_dataset: Optional[Dataset[Any]]) -> None: ...
class AlgorithmFuncAsyncFull(Protocol):
def __call__(
self,
*,
store: LightningStore,
train_dataset: Optional[Dataset[Any]],
val_dataset: Optional[Dataset[Any]],
llm_proxy: Optional[LLMProxy],
adapter: Optional[TraceAdapter[Any]],
initial_resources: Optional[NamedResources],
) -> Awaitable[None]: ...
class AlgorithmFuncAsyncOnlyStore(Protocol):
def __call__(self, *, store: LightningStore) -> Awaitable[None]: ...
class AlgorithmFuncAsyncOnlyDataset(Protocol):
def __call__(
self, *, train_dataset: Optional[Dataset[Any]], val_dataset: Optional[Dataset[Any]]
) -> Awaitable[None]: ...
AlgorithmFuncAsync = Union[AlgorithmFuncAsyncOnlyStore, AlgorithmFuncAsyncOnlyDataset, AlgorithmFuncAsyncFull]
AlgorithmFuncSync = Union[AlgorithmFuncSyncOnlyStore, AlgorithmFuncSyncOnlyDataset, AlgorithmFuncSyncFull]
class AlgorithmFuncSyncFallback(Protocol):
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
class AlgorithmFuncAsyncFallback(Protocol):
def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[Any]: ...
AlgorithmFuncSyncLike = Union[AlgorithmFuncSync, AlgorithmFuncSyncFallback]
AlgorithmFuncAsyncLike = Union[AlgorithmFuncAsync, AlgorithmFuncAsyncFallback]
AlgorithmFunc = Union[AlgorithmFuncSyncLike, AlgorithmFuncAsyncLike]
AsyncFlag = Literal[True, False]
AF = TypeVar("AF", bound=AsyncFlag)
class FunctionalAlgorithm(Algorithm, Generic[AF]):
"""An algorithm wrapper built from a callable implementation.
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
def __init__(self: "FunctionalAlgorithm[Literal[False]]", algorithm_func: AlgorithmFuncSyncLike) -> None: ...
@overload
def __init__(self: "FunctionalAlgorithm[Literal[True]]", algorithm_func: AlgorithmFuncAsyncLike) -> None: ...
def __init__(self, algorithm_func: Union[AlgorithmFuncSyncLike, AlgorithmFuncAsyncLike]) -> None:
"""Wrap a function that implements algorithm behaviour.
Args:
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
self._sig = inspect.signature(algorithm_func)
self._is_async = inspect.iscoroutinefunction(algorithm_func)
# Copy function metadata to preserve type hints and other attributes
functools.update_wrapper(self, algorithm_func) # type: ignore
def is_async(self) -> bool:
return self._is_async
@overload
def run(
self: "FunctionalAlgorithm[Literal[False]]",
train_dataset: Optional[Dataset[Any]] = None,
val_dataset: Optional[Dataset[Any]] = None,
) -> None: ...
@overload
def run(
self: "FunctionalAlgorithm[Literal[True]]",
train_dataset: Optional[Dataset[Any]] = None,
val_dataset: Optional[Dataset[Any]] = None,
) -> Awaitable[None]: ...
def __call__(self, *args: Any, **kwargs: Any) -> Any:
return self._algorithm_func(*args, **kwargs) # type: ignore
def run(
self,
train_dataset: Optional[Dataset[Any]] = None,
val_dataset: Optional[Dataset[Any]] = None,
) -> Union[None, Awaitable[None]]:
"""Execute the wrapped function with injected dependencies.
Args:
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 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:
kwargs["store"] = self.get_store()
if "adapter" in self._sig.parameters:
kwargs["adapter"] = self.get_adapter()
if "llm_proxy" in self._sig.parameters:
kwargs["llm_proxy"] = self.get_llm_proxy()
if "initial_resources" in self._sig.parameters:
kwargs["initial_resources"] = self.get_initial_resources()
if "train_dataset" in self._sig.parameters:
kwargs["train_dataset"] = train_dataset
elif train_dataset is not None:
raise TypeError(
f"train_dataset is provided but not supported by the algorithm function: {self._algorithm_func}"
)
if "val_dataset" in self._sig.parameters:
kwargs["val_dataset"] = val_dataset
elif val_dataset is not None:
raise TypeError(
f"val_dataset is provided but not supported by the algorithm function: {self._algorithm_func}"
)
# both sync and async functions can be called with the same signature
result = self._algorithm_func(**kwargs) # type: ignore[misc]
if self._is_async:
return cast(Awaitable[None], result)
return None
@overload
def algo(func: AlgorithmFuncAsync) -> FunctionalAlgorithm[Literal[True]]: ...
@overload
def algo(func: AlgorithmFuncAsyncFallback) -> FunctionalAlgorithm[Any]: ...
@overload
def algo(func: AlgorithmFuncSync) -> FunctionalAlgorithm[Literal[False]]: ...
@overload
def algo(func: AlgorithmFuncSyncFallback) -> FunctionalAlgorithm[Any]: ...
def algo(
func: Union[
AlgorithmFuncSync,
AlgorithmFuncAsync,
AlgorithmFuncSyncFallback,
AlgorithmFuncAsyncFallback,
],
) -> Union[FunctionalAlgorithm[Literal[False]], FunctionalAlgorithm[Literal[True]]]:
"""Convert a callable into a [`FunctionalAlgorithm`][agentlightning.algorithm.decorator.FunctionalAlgorithm].
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: 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:
FunctionalAlgorithm that proxies the callable while exposing the
`Algorithm` interface.
Examples:
```python
from agentlightning.algorithm.decorator import algo
@algo
def batching_algorithm(*, store, train_dataset, val_dataset):
for sample in train_dataset:
store.enqueue_rollout(input=sample, mode="train")
@algo
async def async_algorithm(*, store, train_dataset=None, val_dataset=None):
await store.enqueue_rollout(input={"prompt": "hello"}, mode="train")
```
"""
return FunctionalAlgorithm(func)
-250
View File
@@ -1,250 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import TYPE_CHECKING, Any, List, Literal, Optional
from agentlightning.types import Attempt, Dataset, Rollout, RolloutStatus, Span
from .base import Algorithm
from .utils import with_llm_proxy, with_store
if TYPE_CHECKING:
from agentlightning.llm_proxy import LLMProxy
from agentlightning.store.base import LightningStore
logger = logging.getLogger(__name__)
__all__ = ["FastAlgorithm", "Baseline"]
class FastAlgorithm(Algorithm):
"""Base class for lightweight algorithms optimised for developer workflows.
Fast algorithms prioritise short feedback loops so an agent developer can run
small-scale experiments without waiting for long-running training jobs to
finish.
"""
def _timestamp_to_iso_str(timestamp: float) -> str:
return datetime.fromtimestamp(timestamp).isoformat()
class Baseline(FastAlgorithm):
"""Reference implementation that streams the full dataset through the rollout queue.
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.
The baseline algorithm will auto-start a LLM proxy if one is provided and not yet started.
Args:
n_epochs: Number of dataset passes to execute for both the train and val
splits during developer experiments.
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,
*,
n_epochs: int = 1,
train_split: float = 0.5,
polling_interval: float = 5.0,
max_queue_length: int = 4,
span_verbosity: Literal["keys", "key_values", "none"] = "keys",
) -> None:
super().__init__()
self.n_epochs = n_epochs
self.train_split = train_split
self.polling_interval = polling_interval
self.max_queue_length = max_queue_length
self.span_verbosity = span_verbosity
if not (0.0 < self.train_split < 1.0):
raise ValueError("train_split must be between 0 and 1.")
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 ""
prefix_msg = f"[Rollout {rollout_id} | Attempt {attempt.attempt_id} | Span {span.span_id}] #{span.sequence_id} ({span.name}) "
elapsed = f"{span.end_time - span.start_time:.2f}" if span.start_time and span.end_time else "unknown"
msg = (
prefix_msg
+ f"From {_timestamp_to_iso_str(span.start_time) if span.start_time else 'unknown'}, "
+ f"to {_timestamp_to_iso_str(span.end_time) if span.end_time else 'unknown'}, "
+ f"{elapsed} seconds. "
)
if self.span_verbosity == "key_values":
msg += f"Attributes: {span.attributes}"
else:
msg += f"Attribute keys: {list(span.attributes.keys())}"
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
rollout_end_time = rollout.end_time or asyncio.get_event_loop().time()
logger.info(
f"[Rollout {rollout_id}] Finished with status {rollout.status} in {rollout_end_time - rollout.start_time:.2f} seconds."
)
# Logs all the attempts and their corresponding spans
attempts = await store.query_attempts(rollout_id)
for attempt in attempts:
logger.info(
"[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:
if self.span_verbosity != "none":
logger.info(self._span_to_string(rollout.rollout_id, attempt, span))
# 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}")
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_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]
mode = "train" if index in train_indices else "val"
rollout = await store.enqueue_rollout(input=sample, mode=mode, resources_id=resources_id)
logger.info(f"[Rollout {rollout.rollout_id}] Enqueued in {mode} mode with sample: {sample}")
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
while True:
rollout = await store.get_rollout_by_id(rollout_id)
if rollout is not None:
if rollout.status in ["succeeded", "failed", "cancelled"]:
# Rollout is finished, log all the data.
await self._handle_rollout_finish(rollout)
# We are done here.
self._finished_rollout_count += 1
logger.info(f"Finished {self._finished_rollout_count} rollouts.")
break
if last_status != rollout.status:
if last_status is not None:
logger.info(f"[Rollout {rollout_id}] Status changed to {rollout.status}.")
else:
logger.info(f"[Rollout {rollout_id}] Status is initialized to {rollout.status}.")
last_status = rollout.status
else:
logger.debug(f"[Rollout {rollout_id}] Status is still {rollout.status}.")
await asyncio.sleep(self.polling_interval)
@with_llm_proxy()
@with_store
async def run(
self,
store: LightningStore, # Injected by decorator - callers should not provide this parameter
llm_proxy: Optional[LLMProxy], # Injected by decorator - callers should not provide this parameter
train_dataset: Optional[Dataset[Any]] = None,
val_dataset: Optional[Dataset[Any]] = None,
) -> None:
"""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 one dataset. Provide train_dataset or val_dataset before running."
)
return
concatenated_dataset = [train_dataset[i] for i in range(train_dataset_length) if train_dataset is not None] + [
val_dataset[i] for i in range(val_dataset_length) if val_dataset is not None
]
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}")
# Currently we only supports a single resource update at the start.
initial_resources = self.get_initial_resources()
if initial_resources is not None:
resource_update = await store.update_resources("default", initial_resources)
resources_id = resource_update.resources_id
logger.info(f"Initial resources set: {initial_resources}")
else:
logger.warning("No initial resources provided. Skip initializing resources.")
resources_id = None
for epoch in range(self.n_epochs):
harvest_tasks: List[asyncio.Task[None]] = []
logger.info(f"Proceeding epoch {epoch + 1}/{self.n_epochs}.")
for index in train_indices + val_indices:
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
logger.info(f"Waiting for {len(harvest_tasks)} harvest tasks to complete...")
if len(harvest_tasks) > 0:
await asyncio.gather(*harvest_tasks)
-177
View File
@@ -1,177 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import functools
import logging
import random
from collections.abc import Coroutine
from typing import (
TYPE_CHECKING,
Any,
Callable,
Concatenate,
Iterator,
List,
Literal,
Optional,
ParamSpec,
Sequence,
TypeVar,
overload,
)
from agentlightning.types import Dataset
if TYPE_CHECKING:
from agentlightning.llm_proxy import LLMProxy
from agentlightning.store.base import LightningStore
from .base import Algorithm
T_task = TypeVar("T_task")
T_algo = TypeVar("T_algo", bound="Algorithm")
P = ParamSpec("P")
R = TypeVar("R")
logger = logging.getLogger(__name__)
def batch_iter_over_dataset(dataset: Dataset[T_task], batch_size: int) -> Iterator[Sequence[T_task]]:
"""
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 = []
def with_store(
func: Callable[Concatenate[T_algo, LightningStore, P], Coroutine[Any, Any, R]],
) -> Callable[Concatenate[T_algo, P], Coroutine[Any, Any, R]]:
"""Inject the algorithm's `LightningStore` into coroutine methods.
The decorator calls `Algorithm.get_store()` once per invocation and passes the
resulting store as an explicit argument to the wrapped coroutine. Decorated
methods therefore receive the resolved store even when invoked by helper
utilities rather than directly by the algorithm.
Args:
func: The coroutine that expects `(self, store, *args, **kwargs)`.
Returns:
A coroutine wrapper that automatically retrieves the store and forwards it
to `func`.
"""
@functools.wraps(func)
async def wrapper(self: T_algo, *args: P.args, **kwargs: P.kwargs) -> R:
store = self.get_store()
return await func(self, store, *args, **kwargs)
return wrapper
@overload
def with_llm_proxy(
required: Literal[False] = False,
auto_start: bool = True,
) -> Callable[
[Callable[Concatenate[T_algo, Optional[LLMProxy], P], Coroutine[Any, Any, R]]],
Callable[Concatenate[T_algo, P], Coroutine[Any, Any, R]],
]: ...
@overload
def with_llm_proxy(
required: Literal[True],
auto_start: bool = True,
) -> Callable[
[Callable[Concatenate[T_algo, LLMProxy, P], Coroutine[Any, Any, R]]],
Callable[Concatenate[T_algo, P], Coroutine[Any, Any, R]],
]: ...
def with_llm_proxy(
required: bool = False,
auto_start: bool = True,
) -> Callable[
[Callable[..., Coroutine[Any, Any, Any]]],
Callable[..., Coroutine[Any, Any, Any]],
]:
"""Resolve and optionally lifecycle-manage the configured LLM proxy.
Args:
required: When True, raises `ValueError` if the algorithm does not have an
[`LLMProxy`][agentlightning.LLMProxy] set. When False, the wrapped coroutine receives
`None` if no proxy is available.
auto_start: When True, [`LLMProxy.start()`][agentlightning.LLMProxy.start] is invoked if the proxy is not
already running before calling `func` and [`LLMProxy.stop()`][agentlightning.LLMProxy.stop] is
called afterwards.
Returns:
A decorator that injects the [`LLMProxy`][agentlightning.LLMProxy] (or `None`) as the first
argument after `self` and manages automatic startup/shutdown when requested.
"""
def decorator(
func: Callable[..., Coroutine[Any, Any, Any]],
) -> Callable[..., Coroutine[Any, Any, Any]]:
@functools.wraps(func)
async def wrapper(self: Algorithm, *args: Any, **kwargs: Any) -> Any:
llm_proxy = self.get_llm_proxy()
if required and llm_proxy is None:
raise ValueError(
"LLM proxy is required but not configured. Call set_llm_proxy() before using this method."
)
auto_started = False
if auto_start and llm_proxy is not None:
if llm_proxy.is_running():
logger.info("Proxy is already running, skipping start")
else:
logger.info("Starting proxy, managed by the algorithm")
await llm_proxy.start()
auto_started = True
try:
# At type level, overloads guarantee that if `required=True`
# then `func` expects a non-optional LLMProxy.
return await func(self, llm_proxy, *args, **kwargs)
finally:
if auto_started and llm_proxy is not None:
logger.info("Stopping proxy, managed by the algorithm")
await llm_proxy.stop()
return wrapper
return decorator
@@ -1,5 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from .interface import VERL
__all__ = ["VERL"]
-178
View File
@@ -1,178 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Optional, Type
from hydra import compose, initialize
from omegaconf import OmegaConf
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
if TYPE_CHECKING:
from agentlightning.verl.daemon import AgentModeDaemon
from agentlightning.verl.trainer import AgentLightningTrainer
class VERL(Algorithm):
"""VERL-powered algorithm that delegates training to the VERL PPO runner.
!!! 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: Dictionary mirroring the overrides passed to the VERL CLI. The
overrides are merged with VERL's packaged defaults via Hydra before
launching training.
trainer_cls: Optional override for the trainer class. Experimental.
daemon_cls: Optional override for the daemon class. Experimental.
Examples:
```python
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],
trainer_cls: Optional[Type[AgentLightningTrainer]] = None,
daemon_cls: Optional[Type[AgentModeDaemon]] = None,
):
super().__init__()
# Compose the base config exactly like your decorator:
with initialize(version_base=None, config_path="pkg://agentlightning/verl"):
base_cfg = compose(config_name="config")
# 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)
self.trainer_cls = trainer_cls
self.daemon_cls = daemon_cls
def run(
self,
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.
"""
from agentlightning.verl.daemon import AgentModeDaemon
from agentlightning.verl.trainer import AgentLightningTrainer
trainer_cls = self.trainer_cls or AgentLightningTrainer
daemon_cls = self.daemon_cls or AgentModeDaemon
try:
store = self.get_store()
except Exception:
print("Store is not set. Assuming v0 execution mode.")
run_ppo(
self.config,
train_dataset=train_dataset,
val_dataset=val_dataset,
store=None,
llm_proxy=None,
adapter=None,
trainer_cls=trainer_cls,
daemon_cls=daemon_cls,
)
else:
print("Store is set. Assuming v1 execution mode.")
llm_proxy = self.get_llm_proxy()
adapter = self.get_adapter()
run_ppo(
self.config,
train_dataset=train_dataset,
val_dataset=val_dataset,
store=store,
llm_proxy=llm_proxy,
adapter=adapter,
trainer_cls=trainer_cls,
daemon_cls=daemon_cls,
)
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}")
-56
View File
@@ -1,56 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent Lightning command line interface entry point."""
from __future__ import annotations
import argparse
import importlib
import sys
from typing import Dict, Iterable, Tuple
_SUBCOMMANDS: Dict[str, Tuple[str, str]] = {
"vllm": ("agentlightning.cli.vllm", "Run the vLLM CLI with Agent Lightning instrumentation."),
"store": ("agentlightning.cli.store", "Run a LightningStore server."),
"prometheus": ("agentlightning.cli.prometheus", "Serve Prometheus metrics from the multiprocess registry."),
"agentops": ("agentlightning.cli.agentops_server", "Start the AgentOps server manager."),
}
_DESCRIPTION = "Agent Lightning CLI entry point.\n\nAvailable subcommands:\n" + "\n".join(
f" {name:<10}{desc}" for name, (_, desc) in _SUBCOMMANDS.items()
)
def main(argv: Iterable[str] | None = None) -> int:
"""Dispatch to the requested Agent Lightning subcommand."""
parser = argparse.ArgumentParser(
prog="agl",
description=_DESCRIPTION,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("subcommand", choices=_SUBCOMMANDS.keys(), help="Subcommand to run.")
parser.add_argument("args", nargs=argparse.REMAINDER, help=argparse.SUPPRESS)
parsed = parser.parse_args(list(argv) if argv is not None else None)
module_name, _ = _SUBCOMMANDS[parsed.subcommand]
module = importlib.import_module(module_name)
entry_point = getattr(module, "main", None)
if entry_point is None:
parser.error(f"Subcommand '{parsed.subcommand}' does not define a callable 'main'")
dispatch_args = parsed.args
original_argv = sys.argv
sys.argv = [f"{parser.prog} {parsed.subcommand}", *dispatch_args]
try:
result = entry_point(dispatch_args or None)
finally:
sys.argv = original_argv
if isinstance(result, int):
return result
return 0
if __name__ == "__main__":
raise SystemExit(main())
-115
View File
@@ -1,115 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Serve Prometheus metrics from the Agent Lightning multiprocess registry."""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
from pathlib import Path
from typing import Iterable
from fastapi import FastAPI
from prometheus_client import make_asgi_app # pyright: ignore[reportUnknownVariableType]
from agentlightning.logging import setup as setup_logging
from agentlightning.utils.metrics import get_prometheus_registry
from agentlightning.utils.server_launcher import PythonServerLauncher, PythonServerLauncherArgs
logger = logging.getLogger(__name__)
def ensure_prometheus_dir() -> str:
"""Ensure PROMETHEUS_MULTIPROC_DIR is set and the directory exists."""
directory = os.getenv("PROMETHEUS_MULTIPROC_DIR")
if directory is None:
raise ValueError("PROMETHEUS_MULTIPROC_DIR is not set.")
Path(directory).mkdir(parents=True, exist_ok=True)
logger.info("Serving Prometheus multiprocess metrics from %s", directory)
return directory
def create_prometheus_app(metrics_path: str = "/v1/prometheus") -> FastAPI:
"""Create a FastAPI app that exposes Prometheus metrics and a health endpoint.
Args:
metrics_path: URL path to expose the Prometheus metrics endpoint on.
Returns:
A FastAPI application ready to serve metrics.
"""
if not metrics_path.startswith("/"):
raise ValueError("metrics_path must start with '/'.")
normalized_path = metrics_path.rstrip("/")
if normalized_path in ("", "/"):
raise ValueError("metrics_path must not be '/'. Choose a sub-path such as /v1/prometheus.")
app = FastAPI(title="Agent Lightning Prometheus exporter", docs_url=None, redoc_url=None)
metrics_app = make_asgi_app(registry=get_prometheus_registry()) # pyright: ignore[reportUnknownVariableType]
app.mount(normalized_path, metrics_app) # pyright: ignore[reportUnknownArgumentType]
@app.get("/health")
async def healthcheck() -> dict[str, str]: # pyright: ignore[reportUnusedFunction]
return {"status": "ok"}
return app
def main(argv: Iterable[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Serve Prometheus metrics outside the LightningStore server.")
parser.add_argument("--host", default="0.0.0.0", help="Host to bind the metrics server to.")
parser.add_argument("--port", type=int, default=4748, help="Port to expose the Prometheus metrics on.")
parser.add_argument(
"--metrics-path",
default="/v1/prometheus",
help="HTTP path used to expose metrics. Must start with '/' and not be the root path.",
)
parser.add_argument(
"--log-level",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Configure the logging level for the metrics server.",
)
parser.add_argument(
"--access-log",
action="store_true",
help="Enable uvicorn access logs. Disabled by default to reduce noise.",
)
args = parser.parse_args(list(argv) if argv is not None else None)
setup_logging(args.log_level)
ensure_prometheus_dir()
try:
app = create_prometheus_app(args.metrics_path)
except ValueError as exc:
logger.error("Failed to configure prometheus app: %s", exc)
return 1
launcher_args = PythonServerLauncherArgs(
host=args.host,
port=args.port,
log_level=getattr(logging, args.log_level),
access_log=args.access_log,
healthcheck_url="/health",
)
launcher = PythonServerLauncher(app, launcher_args)
try:
asyncio.run(launcher.run_forever())
except KeyboardInterrupt:
logger.info("Received shutdown signal. Stopping Prometheus server.")
except RuntimeError as exc:
logger.error("Prometheus server failed to start: %s", exc, exc_info=True)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
-131
View File
@@ -1,131 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Run a LightningStore server for persistent access from multiple processes."""
from __future__ import annotations
import argparse
import asyncio
import logging
from typing import Iterable, List
from agentlightning import setup_logging
from agentlightning.store.client_server import LightningStoreServer
from agentlightning.store.memory import InMemoryLightningStore
from agentlightning.utils.metrics import (
ConsoleMetricsBackend,
MetricsBackend,
MultiMetricsBackend,
PrometheusMetricsBackend,
setup_multiprocess_prometheus,
)
logger = logging.getLogger(__name__)
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(
"--tracker",
nargs="+",
choices=["prometheus", "console"],
help="Enable metrics tracking. Repeat for multiple trackers.",
)
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)
setup_logging(args.log_level)
trackers: List[MetricsBackend] = []
if args.tracker:
if "prometheus" in args.tracker:
logger.info("Enabling Prometheus metrics tracking.")
if args.n_workers > 1:
# This has to be done before prometheus_client is imported
setup_multiprocess_prometheus()
logger.info("Setting up Prometheus multiprocess directory for metrics tracking.")
trackers.append(PrometheusMetricsBackend())
if "console" in args.tracker:
logger.info("Enabling console metrics tracking.")
trackers.append(ConsoleMetricsBackend())
if len(trackers) == 0:
tracker: MetricsBackend | None = None
elif len(trackers) == 1:
tracker = trackers[0]
else:
tracker = MultiMetricsBackend(trackers)
if args.backend == "memory":
store = InMemoryLightningStore(
thread_safe=True, # Using thread_safe store for server
tracker=tracker,
)
elif args.backend == "mongo":
from agentlightning.store.mongo import MongoLightningStore
store = MongoLightningStore(mongo_uri=args.mongo_uri, tracker=tracker)
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,
tracker=tracker,
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
if __name__ == "__main__":
raise SystemExit(main())
-29
View File
@@ -1,29 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
from typing import Iterable
def main(argv: Iterable[str] | None = None) -> int:
import sys
from vllm.entrypoints.cli.main import main as vllm_main
from agentlightning.instrumentation.vllm import instrument_vllm
instrument_vllm()
if argv is not None:
original_argv = sys.argv
sys.argv = [original_argv[0], *list(argv)]
try:
vllm_main()
finally:
sys.argv = original_argv
else:
vllm_main()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+65 -391
View File
@@ -1,408 +1,82 @@
# Copyright (c) Microsoft. All rights reserved.
"""Utilities for interacting with legacy Agent Lightning servers.
"""Thin httpx clients for Agent Lightning."""
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.
"""
from __future__ import annotations
import asyncio
import logging
import time
import urllib.parse
import warnings
from typing import Any, Dict, List, Optional, Union
from typing import Any
import aiohttp
import requests
from .types import NamedResources, ResourcesUpdate, RolloutLegacy, Task, TaskIfAny, TaskInput
logger = logging.getLogger(__name__)
import httpx
class AgentLightningClient:
"""Client wrapper for the legacy version-aware Agent Lightning server.
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"
_resources_uri = "/resources"
_latest_resources_uri = "/resources/latest"
_report_rollout_uri = "/rollout"
def __init__(self, endpoint: str, poll_interval: float = 5.0, timeout: float = 10.0):
"""Initialize the client.
Args:
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
)
self.endpoint = endpoint
self.task_count = 0
self.poll_interval = poll_interval
self.timeout = timeout
self._resource_cache: Dict[str, ResourcesUpdate] = {} # TODO: mechanism to evict cache
self._default_headers = {"X-AgentLightning-Client": "true"}
async def _request_json_async(self, url: str) -> Optional[Dict[str, Any]]:
"""Perform an asynchronous ``GET`` request and parse the JSON payload.
Args:
url: Fully qualified URL to query.
Returns:
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:
try:
async with session.get(url, headers=self._default_headers) as resp:
resp.raise_for_status()
return await resp.json()
except Exception as e:
logger.debug(f"Async GET request failed for {url}: {e}")
return None
async def _post_json_async(self, url: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Perform an asynchronous ``POST`` request with a JSON body.
Args:
url: Fully qualified URL that accepts the payload.
payload: Dictionary that will be serialized and sent as JSON.
Returns:
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:
try:
async with session.post(url, json=payload, headers=self._default_headers) as resp:
resp.raise_for_status()
return await resp.json()
except Exception as e:
logger.debug(f"Async POST request failed for {url}: {e}")
return None
async def poll_next_task_async(self) -> Optional[Task]:
"""Poll the server asynchronously until a task becomes available.
Returns:
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:
response = await self._request_json_async(url)
if response:
task_if_any = TaskIfAny.model_validate(response)
if task_if_any.is_available and task_if_any.task:
self.task_count += 1
logger.info(f"[Task {self.task_count} Received] ID: {task_if_any.task.rollout_id}")
return task_if_any.task
logger.debug(f"No task available yet. Retrying in {self.poll_interval} seconds...")
await asyncio.sleep(self.poll_interval)
async def get_resources_by_id_async(self, resource_id: str) -> Optional[ResourcesUpdate]:
"""Fetch a specific resource bundle by identifier.
Args:
resource_id: Identifier sourced from the task metadata.
Returns:
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.")
return self._resource_cache[resource_id]
url = urllib.parse.urljoin(self.endpoint, f"{self._resources_uri}/{resource_id}")
response = await self._request_json_async(url)
if response:
resources_update = ResourcesUpdate.model_validate(response)
self._resource_cache[resource_id] = resources_update
logger.info(f"Fetched and cached resources for ID: {resource_id}")
return resources_update
return None
async def get_latest_resources_async(self) -> Optional[ResourcesUpdate]:
"""Fetch the most recent resource bundle advertised by the server.
Returns:
[`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)
if response:
resources_update = ResourcesUpdate.model_validate(response)
# Cache this result as well
self._resource_cache[resources_update.resources_id] = resources_update
return resources_update
return None
async def post_rollout_async(self, rollout: RolloutLegacy) -> Optional[Dict[str, Any]]:
"""Submit a completed rollout back to the server.
Args:
rollout: Legacy rollout payload produced by the executor.
Returns:
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]]:
"""Perform a blocking ``GET`` request and parse the JSON payload.
Args:
url: Fully qualified URL to query.
Returns:
Parsed JSON body as a dictionary if the request succeeds; otherwise ``None``.
"""
try:
response = requests.get(url, timeout=self.timeout, headers=self._default_headers)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.debug(f"Sync GET request failed for {url}: {e}")
return None
def _post_json(self, url: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Perform a blocking ``POST`` request with a JSON payload.
Args:
url: Fully qualified URL that accepts the payload.
payload: Dictionary that will be serialized and sent as JSON.
Returns:
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)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.debug(f"Sync POST request failed for {url}: {e}")
return None
def poll_next_task(self) -> Optional[Task]:
"""Poll the server synchronously until a task becomes available.
Returns:
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:
response = self._request_json(url)
if response:
task_if_any = TaskIfAny.model_validate(response)
if task_if_any.is_available and task_if_any.task:
self.task_count += 1
logger.info(f"[Task {self.task_count} Received] ID: {task_if_any.task.rollout_id}")
return task_if_any.task
logger.debug(f"No task available yet. Retrying in {self.poll_interval} seconds...")
time.sleep(self.poll_interval)
def get_resources_by_id(self, resource_id: str) -> Optional[ResourcesUpdate]:
"""Fetch a specific resource bundle by identifier.
Args:
resource_id: Identifier sourced from the task metadata.
Returns:
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.")
return self._resource_cache[resource_id]
url = urllib.parse.urljoin(self.endpoint, f"{self._resources_uri}/{resource_id}")
response = self._request_json(url)
if response:
resources_update = ResourcesUpdate.model_validate(response)
self._resource_cache[resource_id] = resources_update
logger.info(f"Fetched and cached resources for ID: {resource_id}")
return resources_update
return None
def get_latest_resources(self) -> Optional[ResourcesUpdate]:
"""Fetch the most recent resource bundle advertised by the server.
Returns:
[`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)
if response:
resources_update = ResourcesUpdate.model_validate(response)
self._resource_cache[resources_update.resources_id] = resources_update
return resources_update
return None
def post_rollout(self, rollout: RolloutLegacy) -> Optional[Dict[str, Any]]:
"""Submit a completed rollout back to the server.
Args:
rollout: Legacy rollout payload produced by the executor.
Returns:
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 self._post_json(url, payload)
def _headers_with_key(headers: httpx.Headers | dict[str, str] | None, key: str | None) -> dict[str, str]:
merged = dict(headers or {})
if key:
merged["Authorization"] = f"Bearer {key}"
return merged
class DevTaskLoader(AgentLightningClient):
"""In-memory task loader used for development and integration tests.
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.
!!! warning "Deprecated"
[`DevTaskLoader`][agentlightning.client.DevTaskLoader] is a compatibility shim.
Prefer [`Trainer.dev`][agentlightning.Trainer.dev] for new code.
"""
class AgentLightningAsyncClient(httpx.AsyncClient):
"""Async httpx client with optional bearer key."""
def __init__(
self,
tasks: Union[List[TaskInput], List[Task]],
resources: Union[NamedResources, ResourcesUpdate],
*,
key: str | None = None,
headers: httpx.Headers | dict[str, str] | None = None,
**kwargs: Any,
):
"""Initialize the loader with predefined tasks and resources.
) -> None:
super().__init__(
headers=_headers_with_key(headers, key),
**kwargs,
)
Args:
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.
class AgentLightningSyncClient(httpx.Client):
"""Sync httpx client with optional bearer key."""
def __init__(
self,
*,
key: str | None = None,
headers: httpx.Headers | dict[str, str] | None = None,
max_retries: int = 10,
**kwargs: Any,
) -> None:
self.max_retries = max_retries
super().__init__(
headers=_headers_with_key(headers, key),
**kwargs,
)
def get(self, *args: Any, **kwargs: Any) -> httpx.Response: # type: ignore[override]
last_exc: Exception | None = None
for attempt in range(self.max_retries + 1):
try:
return super().get(*args, **kwargs)
except Exception as exc:
last_exc = exc
print(f"GET failed (attempt {attempt + 1}/{self.max_retries + 1}): {exc}")
assert last_exc is not None
raise last_exc
def post_with_retry(self, *args: Any, **kwargs: Any) -> httpx.Response:
"""POST with retry + backoff, raising on non-2xx. Only for idempotent endpoints.
Retries both transport errors and error status codes, so a transient 5xx
is retried too. Callers get an already status-checked response back.
"""
warnings.warn("DevTaskLoader is deprecated. Please use Trainer.dev instead.", DeprecationWarning)
super().__init__(endpoint="local://", **kwargs)
self._tasks = tasks.copy()
if len(self._tasks) == 0:
raise ValueError("DevTaskLoader requires at least one task to be provided.")
# Check if tasks are mixture of TaskInput and Task
if any(isinstance(task, Task) for task in self._tasks):
if not all(isinstance(task, Task) for task in self._tasks):
raise ValueError("All tasks must be either Task or TaskInput objects.")
self._task_index = 0
if isinstance(resources, ResourcesUpdate):
self._resources_update = resources
else:
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 the rollouts posted back to the loader during development runs."""
return self._rollouts
def poll_next_task(self) -> Optional[Task]:
"""Return the next task from the local queue.
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:
Next task to execute.
"""
if self._task_index >= len(self._tasks):
self._task_index = 0
task_or_input = self._tasks[self._task_index]
if isinstance(task_or_input, Task):
task = task_or_input
else:
rollout_id = f"local_task_{self._task_index + 1:03d}"
task = Task(
rollout_id=rollout_id,
input=task_or_input,
resources_id=self._resources_update.resources_id,
create_time=time.time(),
)
self._task_index += 1
self.task_count += 1
logger.info(f"[Task {self.task_count} Received] Task ID: {task.rollout_id}")
return task
def get_resources_by_id(self, resource_id: str) -> Optional[ResourcesUpdate]:
logger.debug(f"DevTaskLoader checking resources for ID: {resource_id}")
if resource_id != self._resources_update.resources_id:
raise ValueError(
f"Resource ID '{resource_id}' not found. Only '{self._resources_update.resources_id}' is available."
)
return self._resources_update
def get_latest_resources(self) -> Optional[ResourcesUpdate]:
logger.debug("DevTaskLoader returning latest resources.")
return self._resources_update
def post_rollout(self, rollout: RolloutLegacy) -> Optional[Dict[str, Any]]:
logger.debug(f"DevTaskLoader received rollout for task: {rollout.rollout_id}")
self._rollouts.append(rollout)
return {"status": "received", "rollout_id": rollout.rollout_id}
async def poll_next_task_async(self) -> Optional[Task]:
return self.poll_next_task()
async def get_resources_by_id_async(self, resource_id: str) -> Optional[ResourcesUpdate]:
return self.get_resources_by_id(resource_id)
async def get_latest_resources_async(self) -> Optional[ResourcesUpdate]:
return self.get_latest_resources()
async def post_rollout_async(self, rollout: RolloutLegacy) -> Optional[Dict[str, Any]]:
return self.post_rollout(rollout)
def __repr__(self):
return f"DevTaskLoader(num_tasks={len(self._tasks)}, resources={self._resources_update.resources})"
last_exc: Exception | None = None
for attempt in range(self.max_retries + 1):
try:
response = super().post(*args, **kwargs)
response.raise_for_status()
return response
except Exception as exc:
last_exc = exc
print(f"POST failed (attempt {attempt + 1}/{self.max_retries + 1}): {exc}")
if attempt < self.max_retries:
time.sleep(min(2 ** (attempt + 1), 30))
assert last_exc is not None
raise last_exc
-348
View File
@@ -1,348 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""
This file is not carefully reviewed.
It might contain unintentional bugs and issues.
Please always review the parsed construction arguments before using them.
"""
from __future__ import annotations
import argparse
import inspect
import logging
from typing import _GenericAlias # type: ignore
from typing import (
Any,
Callable,
Dict,
List,
Tuple,
Type,
TypeVar,
Union,
get_args,
get_origin,
get_type_hints,
overload,
)
CliConfigurable = Any
logger = logging.getLogger(__name__)
__all__ = ["lightning_cli"]
# TypeVars for precise return type hinting with overloads
_C = TypeVar("_C", bound=CliConfigurable)
_C1 = TypeVar("_C1", bound=CliConfigurable)
_C2 = TypeVar("_C2", bound=CliConfigurable)
_C3 = TypeVar("_C3", bound=CliConfigurable)
_C4 = TypeVar("_C4", bound=CliConfigurable)
# Custom type for CLI arguments that can be string or None
def nullable_str(value: str) -> str | None:
"""Converts specific string values (case-insensitive) to None, otherwise returns the string."""
if value.lower() in ["none", "null", "~", "nil"]: # Define keywords for None
return None
return value
def nullable_int(value: str) -> int | None:
"""Converts specific string values (case-insensitive) to None, otherwise returns the integer."""
if value.lower() in ["none", "null", "~", "nil"]: # Define keywords for None
return None
try:
return int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"Invalid integer value: '{value}'")
def nullable_float(value: str) -> float | None:
"""Converts specific string values (case-insensitive) to None, otherwise returns the float."""
if value.lower() in ["none", "null", "~", "nil"]: # Define keywords for None
return None
try:
return float(value)
except ValueError:
raise argparse.ArgumentTypeError(f"Invalid float value: '{value}'")
def _str_to_bool(v: str) -> bool:
"""Converts common string representations of bool to Python bool (case-insensitive)."""
if isinstance(v, bool): # type: ignore
return v # Allow passing bools directly if used programmatically
lowered_v = v.lower()
if lowered_v in ("yes", "true", "t", "y", "1"):
return True
elif lowered_v in ("no", "false", "f", "n", "0"):
return False
else:
raise argparse.ArgumentTypeError(f"Boolean value expected (e.g., 'true', 'false', 'yes', 'no'), got '{v}'")
def _get_param_type_details(param_annotation: Any) -> Tuple[Any, bool, bool]:
"""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
current_type = param_annotation
# Check for outer Optional
origin = get_origin(current_type)
if origin is Union:
union_args = get_args(current_type)
if len(union_args) == 2 and type(None) in union_args:
is_optional = True
current_type = next(arg for arg in union_args if arg is not type(None)) # Unwrap Optional
# Check if the (potentially unwrapped) type is a List
origin = get_origin(current_type) # Re-check origin after potential unwrap
if origin is list or (isinstance(current_type, _GenericAlias) and current_type.__origin__ is list):
is_list = True
return current_type, is_optional, is_list
def _determine_argparse_type(param_type: Any) -> Callable[[str], Any]:
"""Determines the type for argparse based on parameter type details."""
core_type, is_optional, _ = _get_param_type_details(param_type)
if core_type is str and is_optional:
return nullable_str # Special handling for Optional[str]
elif core_type is int and is_optional:
return nullable_int
elif core_type is float and is_optional:
return nullable_float
elif core_type is bool:
return _str_to_bool # Special handling for bool
elif core_type in (int, float, str):
return core_type
return str # Default to str if no specific type is provided (including empty)
def _determine_argparse_type_and_nargs(
core_param_type: Any, is_param_list: bool # The type after unwrapping an outer Optional
) -> Dict[str, Any]:
"""Determines the 'type' and 'nargs' for argparse based on parameter type details."""
kwargs: Dict[str, Any] = {}
if is_param_list:
kwargs["nargs"] = "*" # Allows zero or more arguments for lists
list_item_annotations = get_args(core_param_type) # For List[T], core_param_type is List[T]
if list_item_annotations and list_item_annotations[0] is not Any:
item_ann = list_item_annotations[0]
# Check if the list item itself is, e.g., Optional[str] or bool
kwargs["type"] = _determine_argparse_type(item_ann)
else:
kwargs["type"] = str
else: # Not a list
kwargs["type"] = _determine_argparse_type(core_param_type)
return kwargs
def _build_help_string(cls_name: str, param_name: str, core_type: Any, is_optional: bool, is_list: bool) -> str:
"""Constructs a descriptive help string for a CLI argument."""
type_display_name = "Any"
if core_type is not inspect.Parameter.empty:
type_display_name = getattr(core_type, "__name__", str(core_type))
if is_list:
list_item_args = get_args(core_type) # core_type is List[T] here
item_name = "Any"
if list_item_args and list_item_args[0] is not Any:
inner_item_core_type, inner_item_optional, _ = _get_param_type_details(list_item_args[0])
item_name = getattr(inner_item_core_type, "__name__", str(inner_item_core_type))
if inner_item_optional: # e.g. List[Optional[str]]
item_name = f"Optional[{item_name}]"
type_display_name = f"List[{item_name}]"
full_type_display = f"Optional[{type_display_name}]" if is_optional and not is_list else type_display_name
if is_optional and is_list: # e.g. Optional[List[str]]
full_type_display = f"Optional[{type_display_name}]"
help_str = f"For {cls_name}: '{param_name}'. Inferred type: {full_type_display}."
return help_str
def _add_argument_for_parameter(
parser: argparse.ArgumentParser,
cls: Type[CliConfigurable],
param_name: str,
param_obj: inspect.Parameter,
dest_name: str,
resolved_param_annotation: Any = None,
) -> None:
"""Configures and adds a single CLI argument for an __init__ parameter."""
if resolved_param_annotation is None:
param_type_annotation = param_obj.annotation
else:
param_type_annotation = resolved_param_annotation
# core_type is the main type (e.g., int, str, List[str]), after unwrapping the outermost Optional.
# is_overall_optional indicates if the parameter itself can be None (e.g. param: Optional[T] = None)
# is_list indicates if core_type is a List.
core_type, is_overall_optional, is_list = _get_param_type_details(param_type_annotation)
has_init_default = param_obj.default is not inspect.Parameter.empty
init_default_value = param_obj.default if has_init_default else None
argparse_kwargs = _determine_argparse_type_and_nargs(core_type if is_list else param_type_annotation, is_list)
if has_init_default:
argparse_kwargs["default"] = init_default_value
elif is_overall_optional: # Parameter is Optional (e.g. Optional[int]) and no explicit default in __init__
argparse_kwargs["default"] = None # So, if not provided on CLI, it becomes None.
argparse_kwargs["help"] = _build_help_string(cls.__name__, param_name, core_type, is_overall_optional, is_list)
if not has_init_default and not is_overall_optional: # Required if no __init__ default AND not Optional
argparse_kwargs["required"] = True
if "default" in argparse_kwargs: # Should not happen if logic is correct
del argparse_kwargs["default"]
cli_arg_name = f"--{cls.__name__.lower()}.{param_name.replace('_', '-')}"
parser.add_argument(cli_arg_name, dest=dest_name, **argparse_kwargs)
def _add_arguments_for_class(
parser: argparse.ArgumentParser,
cls: Type[CliConfigurable],
class_arg_configs_maps: Dict[Type[CliConfigurable], Dict[str, str]], # Maps cls to {param_name: dest_name}
) -> None:
"""Adds all relevant CLI arguments for a given class by processing its __init__ parameters."""
cls_name_lower = cls.__name__.lower()
sig = inspect.signature(cls.__init__)
try:
# Resolve string annotations to actual types using get_type_hints.
# For methods, get_type_hints automatically uses obj.__globals__ for globalns.
resolved_hints = get_type_hints(cls.__init__)
except Exception as e:
logger.warning(
f"Could not resolve type hints for {cls.__name__}.__init__ using get_type_hints: {e}. "
f"CLI argument parsing for this class might be based on string annotations, "
"which could be unreliable for complex types."
)
resolved_hints = {} # Fallback to an empty dict if resolution fails
if cls not in class_arg_configs_maps: # Ensure the class entry exists
class_arg_configs_maps[cls] = {}
for param_name, param_obj in sig.parameters.items():
if param_name == "self": # Skip 'self'
continue
dest_name = f"{cls_name_lower}_{param_name}" # Unique destination for argparse
class_arg_configs_maps[cls][param_name] = dest_name # Store mapping for later instantiation
# Use the resolved hint if available, otherwise fallback to param_obj.annotation (which might be a string)
actual_param_annotation = resolved_hints.get(param_name, param_obj.annotation)
_add_argument_for_parameter(parser, cls, param_name, param_obj, dest_name, actual_param_annotation)
def _create_argument_parser() -> argparse.ArgumentParser:
"""Creates and returns the main ArgumentParser with default settings."""
return argparse.ArgumentParser(
description="CLI configurator for application components.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter, # Automatically shows default values in help
)
def _instantiate_classes(
parsed_args: argparse.Namespace,
classes: Tuple[Type[CliConfigurable], ...],
class_arg_configs_maps: Dict[Type[CliConfigurable], Dict[str, str]],
) -> Tuple[CliConfigurable, ...]:
"""Instantiates classes using the parsed CLI arguments and the stored mappings."""
instances_list: List[CliConfigurable] = []
for cls in classes:
constructor_args: Dict[str, Any] = {}
# Get the {__init__ param_name: argparse_dest_name} map for the current class
param_to_dest_map = class_arg_configs_maps.get(cls, {})
sig = inspect.signature(cls.__init__)
for param_name_in_sig, _ in sig.parameters.items():
if param_name_in_sig == "self":
continue
dest_name_for_arg = param_to_dest_map.get(param_name_in_sig)
if dest_name_for_arg and hasattr(parsed_args, dest_name_for_arg):
value = getattr(parsed_args, dest_name_for_arg)
constructor_args[param_name_in_sig] = value
# If an argument was required by argparse, parse_args() would have exited if missing.
# If not required and not provided, its default value (set by argparse) is used.
try:
logger.info("Instantiating %s with args: %s", cls.__name__, constructor_args)
instances_list.append(cls(**constructor_args))
except Exception as e:
parsed_args_for_cls = {
k: getattr(parsed_args, v) for k, v in param_to_dest_map.items() if hasattr(parsed_args, v)
}
logger.error(
f"Error instantiating {cls.__name__} with resolved args {constructor_args}. "
f"Parsed args for class: "
f"{parsed_args_for_cls}. "
f"Error: {e}"
)
raise
return tuple(instances_list)
@overload
def lightning_cli(cls1: Type[_C1]) -> _C1: ...
@overload
def lightning_cli(cls1: Type[_C1], cls2: Type[_C2]) -> Tuple[_C1, _C2]: ...
@overload
def lightning_cli(cls1: Type[_C1], cls2: Type[_C2], cls3: Type[_C3]) -> Tuple[_C1, _C2, _C3]: ...
@overload
def lightning_cli(cls1: Type[_C1], cls2: Type[_C2], cls3: Type[_C3], cls4: Type[_C4]) -> Tuple[_C1, _C2, _C3, _C4]: ...
@overload # Fallback for more than 4 or a dynamic number of classes
def lightning_cli(*classes: Type[CliConfigurable]) -> Tuple[CliConfigurable, ...]: ...
# FIXME: lightning_cli needs to be fixed to comply with the latest trainer implementation.
def lightning_cli(*classes: Type[CliConfigurable]) -> CliConfigurable | Tuple[CliConfigurable, ...]: # type: ignore
"""
Parses command-line arguments to configure and instantiate provided CliConfigurable classes.
Args:
*classes: One or more classes that inherit from CliConfigurable. Each class's
__init__ parameters will be exposed as command-line arguments.
Returns:
A tuple of instantiated objects, corresponding to the input classes in order.
"""
if not classes:
return tuple() # Return an empty tuple if no classes are provided
parser = _create_argument_parser()
# This map will store {cls: {init_param_name: argparse_dest_name}}
class_arg_configs_maps: Dict[Type[CliConfigurable], Dict[str, str]] = {}
for cls in classes:
_add_arguments_for_class(parser, cls, class_arg_configs_maps)
parsed_args = parser.parse_args() # Uses sys.argv[1:] by default
# Correctly handle single class case for return type matching overloads
instances = _instantiate_classes(parsed_args, classes, class_arg_configs_maps)
if len(classes) == 1:
return instances[0]
return instances
+3
View File
@@ -0,0 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
"""Hydra configuration package for Agent Lightning."""
+19
View File
@@ -0,0 +1,19 @@
runner_type: k8s # k8s | local
agl_server:
url: http://localhost:8080
# Optional external URL for agent pods. If unset, falls back to agl_server.url.
# Example for minikube docker driver:
# agent_url: http://host.minikube.internal:8080
agent_url: null
key: ""
k8s_runner:
namespace: default
ttl_after_finished: 1200
max_jobs_per_minute: 100
poll_interval: 5
local_runner:
maximum_size: 50
poll_interval: 10
+10
View File
@@ -0,0 +1,10 @@
host: 0.0.0.0
port: 8080
key: ""
default_proxy:
model_name: "Qwen/Qwen2.5-7B-Instruct"
include_log_probs: True
train:
temperature: 1
val:
temperature: 0.7
+52
View File
@@ -0,0 +1,52 @@
# Copyright (c) Microsoft. All rights reserved.
"""Hydra entrypoint for the Agent Lightning controller."""
from __future__ import annotations
import asyncio
import contextlib
import signal
import hydra
from omegaconf import DictConfig
from agentlightning.client import AgentLightningAsyncClient
async def _run_controller(config: DictConfig) -> None:
async with AgentLightningAsyncClient(
base_url=str(config.agl_server.url),
key=str(config.agl_server.key or "") or None,
) as api:
if config.runner_type == "k8s":
try:
from agentlightning.controller.k8s_reconciler import K8sReconciler
except ImportError:
raise RuntimeError("kr8s unavailable - install agentlightning[controller]") from None
reconciler = K8sReconciler(api=api, config=config)
elif config.runner_type == "local":
from agentlightning.controller.local_reconciler import LocalReconciler
reconciler = LocalReconciler(
api=api,
config=config,
)
else:
raise ValueError(f"unknown runner_type: {config.runner_type}")
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, reconciler.stop)
await reconciler.run()
@hydra.main(version_base=None, config_path="../config", config_name="controller")
def main(config: DictConfig) -> None:
asyncio.run(_run_controller(config))
if __name__ == "__main__":
main()
+362
View File
@@ -0,0 +1,362 @@
# Copyright (c) Microsoft. All rights reserved.
"""K8s controller reconciler — manages rollout lifecycle via K8s Jobs.
Two concurrent tasks:
1. periodic_reconcile() — poll queuing rollouts, create Jobs, expire stale
2. watch_jobs() — react to Job completions/failures, update rollout status
Uses AgentLightningAsyncClient for store access and kr8s for K8s API.
"""
from __future__ import annotations
import asyncio
import json
import time
from collections import deque
from typing import Any, cast
import httpx
import kr8s
import kr8s.asyncio
import structlog
import yaml
from jinja2 import Environment
from kr8s.asyncio import objects as k8s_objects
from omegaconf import DictConfig
from agentlightning.client import AgentLightningAsyncClient
from agentlightning.schemas import DEFAULT_ATTEMPT_ID, Rollout, RolloutPatch, RolloutState, RolloutStatusPatch
log = structlog.get_logger()
MANAGED_BY_SELECTOR = "app.kubernetes.io/managed-by=agentlightning"
JOB_CREATION_WINDOW_SECONDS = 60
def build_job_name(rollout_id: str) -> str:
"""Deterministic Job name from rollout ID."""
return f"agl-rollout-{rollout_id}"
def build_job_spec(rollout: Rollout, controller_config: DictConfig) -> dict[str, Any]:
"""Build a K8s Job manifest from the rollout's complete Jinja2 Job template."""
template = rollout.config.k8s.job_template if rollout.config.k8s else None
if not template:
raise ValueError("invalid rollout config: missing config.k8s.job_template")
env = Environment()
env.filters["yaml_escape"] = lambda value: json.dumps(str(value), ensure_ascii=True)
rendered = env.from_string(template).render(
job_name=build_job_name(rollout.rollout_id),
input=rollout.input,
)
docs = [doc for doc in yaml.safe_load_all(rendered) if doc is not None]
if len(docs) != 1:
raise ValueError("invalid rollout config: config.k8s.job_template must render exactly one YAML document")
job = docs[0]
if not isinstance(job, dict) or job.get("kind") != "Job":
raise ValueError("invalid rollout config: config.k8s.job_template must render a Kubernetes Job")
metadata = job.setdefault("metadata", {})
metadata["name"] = build_job_name(rollout.rollout_id)
metadata["namespace"] = controller_config.k8s_runner.namespace
labels = metadata.setdefault("labels", {})
labels["app.kubernetes.io/managed-by"] = "agentlightning"
labels["agentlightning/rollout-id"] = rollout.rollout_id
labels["agentlightning/attempt-id"] = DEFAULT_ATTEMPT_ID
spec = job.setdefault("spec", {})
spec["backoffLimit"] = 0
spec["ttlSecondsAfterFinished"] = controller_config.k8s_runner.ttl_after_finished
if rollout.config.timeout_seconds:
spec["activeDeadlineSeconds"] = rollout.config.timeout_seconds
pod_spec = spec.setdefault("template", {}).setdefault("spec", {})
pod_spec["restartPolicy"] = "Never"
mode = "train" if rollout.is_train else "val"
agent_base_url = str(
controller_config.agl_server.get("agent_url", None) or controller_config.agl_server.url
).rstrip("/")
agl_openai_base_url = (
f"{agent_base_url}/proxy/rollout/{rollout.rollout_id}/attempt/{DEFAULT_ATTEMPT_ID}/mode/{mode}/openai/v1"
)
for container in pod_spec.get("containers", []):
env = container.setdefault("env", [])
for name, value in {
"AGL_OPENAI_BASE_URL": agl_openai_base_url,
"AGL_EVENT_URL": (
f"{agent_base_url}/api/rollouts/{rollout.rollout_id}/attempt/{DEFAULT_ATTEMPT_ID}/events"
),
"AGL_KEY": str(controller_config.agl_server.key or ""),
}.items():
existing = next((item for item in env if item.get("name") == name), None)
if existing is None:
env.append({"name": name, "value": value})
else:
existing.clear()
existing.update({"name": name, "value": value})
return job
class K8sReconciler:
"""Main controller loop. Reconciles rollouts into K8s Jobs.
Args:
api: AgentLightningAsyncClient for store access.
config: Controller configuration.
"""
def __init__(self, api: AgentLightningAsyncClient, config: DictConfig) -> None:
self._api = api
self._config = config
self._runner_config = config.k8s_runner
self._namespace = str(self._runner_config.namespace)
self._k8s_api: Any | None = None
self._stop = asyncio.Event()
self._job_creation_timestamps: deque[float] = deque()
async def _get_k8s_api(self) -> Any:
if self._k8s_api is None:
self._k8s_api = await kr8s.asyncio.api()
return self._k8s_api
async def run(self) -> None:
"""Start both reconcile loops. Blocks until stop() is called."""
log.info(
"Controller starting",
namespace=self._namespace,
poll_interval=self._runner_config.poll_interval,
)
try:
await asyncio.gather(
self._periodic_reconcile_loop(),
self._watch_jobs_loop(),
)
except asyncio.CancelledError:
log.info("Controller stopped")
def stop(self) -> None:
"""Signal the controller to stop."""
self._stop.set()
# --- Periodic reconcile ---
async def _periodic_reconcile_loop(self) -> None:
"""Poll queuing rollouts and reconcile."""
while not self._stop.is_set():
try:
await self._reconcile_once()
except Exception:
log.exception("Periodic reconcile error")
# Sleep with cancellation support.
try:
await asyncio.wait_for(self._stop.wait(), timeout=self._runner_config.poll_interval)
return # stop was set
except TimeoutError:
pass
async def _reconcile_once(self) -> None:
"""One reconcile cycle: align queuing/running rollouts with K8s Jobs."""
rollouts = await self._query_rollouts(state_in=[RolloutState.QUEUING, RolloutState.RUNNING], limit=500)
api = await self._get_k8s_api()
jobs = [
cast(k8s_objects.Job, job).raw
async for job in k8s_objects.Job.async_list(
namespace=self._namespace,
label_selector=MANAGED_BY_SELECTOR,
api=api,
)
]
jobs_by_name = {job.get("metadata", {}).get("name", ""): job for job in jobs}
for rollout in rollouts:
job_name = rollout.status.k8s_job_name or build_job_name(rollout.rollout_id)
job = jobs_by_name.get(job_name)
if job is None:
if rollout.status.state == RolloutState.QUEUING:
await self._create_job(rollout)
continue
log.warning("Orphaned running rollout — Job gone", rollout_id=rollout.rollout_id, job_name=job_name)
await self._patch_status(rollout.rollout_id, state=RolloutState.FAILED, error_message="Job disappeared")
continue
attempt_id = (
job.get("metadata", {}).get("labels", {}).get("agentlightning/attempt-id") or DEFAULT_ATTEMPT_ID
)
job_status = job.get("status", {})
state = None
error_message = None
for condition in job_status.get("conditions", []):
if condition.get("status") != "True":
continue
if condition.get("type") == "Complete":
state = RolloutState.SUCCEEDED
break
if condition.get("type") == "Failed":
reason = condition.get("reason", "Unknown")
message = condition.get("message", "")
error_message = f"Job failed: {reason}"
if message:
error_message += f"{message}"
state = RolloutState.FAILED
break
if state is None and job_status.get("succeeded", 0) > 0:
state = RolloutState.SUCCEEDED
elif state is None and job_status.get("failed", 0) > 0:
state = RolloutState.FAILED
error_message = "Job failed"
if state is None:
if rollout.status.state == RolloutState.QUEUING:
await self._patch_status(
rollout.rollout_id,
state=RolloutState.RUNNING,
k8s_job_name=job_name,
last_attempt_id=attempt_id,
)
continue
if rollout.status.state == RolloutState.QUEUING and state == RolloutState.SUCCEEDED:
patched = await self._patch_status(
rollout.rollout_id,
state=RolloutState.RUNNING,
k8s_job_name=job_name,
last_attempt_id=attempt_id,
)
if not patched:
continue
await self._patch_status(
rollout.rollout_id,
state=state,
k8s_job_name=job_name,
last_attempt_id=attempt_id,
error_message=error_message,
)
async def _create_job(self, rollout: Rollout) -> None:
"""Create a K8s Job for a queuing rollout without changing rollout state."""
job_name = build_job_name(rollout.rollout_id)
now = time.monotonic()
window_start = now - JOB_CREATION_WINDOW_SECONDS
while self._job_creation_timestamps and self._job_creation_timestamps[0] <= window_start:
self._job_creation_timestamps.popleft()
if len(self._job_creation_timestamps) >= self._runner_config.max_jobs_per_minute:
log.info(
"Job creation rate limit reached — deferring queued rollouts",
rollout_id=rollout.rollout_id,
jobs_in_last_minute=len(self._job_creation_timestamps),
max_jobs_per_minute=self._runner_config.max_jobs_per_minute,
)
return
try:
manifest = build_job_spec(rollout, self._config)
attempt_id = manifest["metadata"]["labels"]["agentlightning/attempt-id"]
api = await self._get_k8s_api()
job = k8s_objects.Job(manifest, api=api)
await job.async_create()
self._job_creation_timestamps.append(time.monotonic())
log.info("Job created", rollout_id=rollout.rollout_id, job_name=job_name, attempt_id=attempt_id)
except Exception as exc:
error_str = str(exc)
lower_error = error_str.lower()
if "422" in lower_error or "unprocessable" in lower_error or "invalid" in lower_error:
log.error("Invalid Job spec — marking failed", rollout_id=rollout.rollout_id, error=error_str)
await self._patch_status(
rollout.rollout_id,
state=RolloutState.FAILED,
error_message=f"Invalid Job spec: {error_str}",
)
else:
log.warning("Job creation failed — will retry", rollout_id=rollout.rollout_id, error=error_str)
# --- Watch Jobs ---
async def _watch_jobs_loop(self) -> None:
"""Watch K8s Job events and react to completions/failures."""
while not self._stop.is_set():
try:
watcher = kr8s.asyncio.watch(
"jobs",
namespace=self._namespace,
label_selector=MANAGED_BY_SELECTOR,
api=await self._get_k8s_api(),
)
async for event_type, obj in watcher:
if self._stop.is_set():
return
if event_type in ("MODIFIED", "ADDED"):
await self._handle_job_event(obj.raw)
except Exception:
log.exception("Watch error — restarting watch")
await asyncio.sleep(5)
async def _handle_job_event(self, job: dict[str, Any]) -> None:
"""Process a Job event — check conditions, update rollout status."""
labels = job.get("metadata", {}).get("labels", {})
rollout_id = labels.get("agentlightning/rollout-id")
if not rollout_id:
return
attempt_id = labels.get("agentlightning/attempt-id") or DEFAULT_ATTEMPT_ID
conditions = job.get("status", {}).get("conditions", [])
if not conditions:
return
for condition in conditions:
cond_type = condition.get("type", "")
cond_status = condition.get("status", "")
if cond_status != "True":
continue
if cond_type == "Complete":
log.info("Job completed", rollout_id=rollout_id, last_attempt_id=attempt_id)
await self._patch_status(rollout_id, state=RolloutState.SUCCEEDED, last_attempt_id=attempt_id)
return
elif cond_type == "Failed":
reason = condition.get("reason", "Unknown")
message = condition.get("message", "")
error_msg = f"Job failed: {reason}"
if message:
error_msg += f"{message}"
log.info("Job failed", rollout_id=rollout_id, last_attempt_id=attempt_id, reason=reason)
await self._patch_status(
rollout_id,
state=RolloutState.FAILED,
last_attempt_id=attempt_id,
error_message=error_msg,
)
return
async def _query_rollouts(
self,
*,
state_in: list[RolloutState],
limit: int = 50,
) -> list[Rollout]:
params = httpx.QueryParams()
for state in state_in:
params = params.add("state_in", state.value)
params = params.add("limit", limit)
response = await self._api.get("/api/rollouts", params=params)
response.raise_for_status()
return [Rollout.model_validate(item) for item in response.json()]
async def _patch_status(self, rollout_id: str, **status: Any) -> bool:
try:
patch = RolloutPatch(status=RolloutStatusPatch.model_validate(status))
response = await self._api.patch(
f"/api/rollouts/{rollout_id}",
json=patch.model_dump(mode="json", exclude_unset=True),
)
response.raise_for_status()
return True
except Exception as exc:
log.warning("Failed to patch rollout", rollout_id=rollout_id, error=str(exc))
return False
@@ -0,0 +1,283 @@
# Copyright (c) Microsoft. All rights reserved.
"""Local reconciler that runs rollouts as short-lived Python subprocesses."""
from __future__ import annotations
import asyncio
import contextlib
import importlib
import inspect
import json
import os
import signal
import sys
import time
import traceback
from dataclasses import dataclass
import httpx
import structlog
from omegaconf import DictConfig
from agentlightning.client import AgentLightningAsyncClient
from agentlightning.schemas import DEFAULT_ATTEMPT_ID, Rollout, RolloutPatch, RolloutState, RolloutStatusPatch
log = structlog.get_logger()
_SHUTDOWN_WAIT_TIMEOUT = 5.0
def _run_local_reconciler_worker(agent_class_path: str) -> int:
try:
if ":" in agent_class_path:
module_name, class_name = agent_class_path.split(":", 1)
else:
module_name, class_name = agent_class_path.rsplit(".", 1)
loaded = getattr(importlib.import_module(module_name), class_name)
if not isinstance(loaded, type):
raise TypeError(f"{agent_class_path} is not a class")
result = loaded().run()
if inspect.isawaitable(result):
asyncio.run(result) # type: ignore[arg-type]
return 0
except Exception:
traceback.print_exc()
return 1
@dataclass
class Proc:
"""In-flight local subprocess."""
attempt_id: str
proc: asyncio.subprocess.Process
spawned_at: float
killed: bool = False
def _build_env_from_map(task_input: object, env_map: dict[str, str]) -> dict[str, str]:
env: dict[str, str] = {}
for name, path in env_map.items():
value = _resolve_input_path(task_input, path)
if isinstance(value, str):
env[name] = value
continue
try:
env[name] = json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError) as exc:
raise ValueError(f"local.env_map.{name} value is not JSON serializable") from exc
return env
def _resolve_input_path(task_input: object, path: str) -> object:
if path == "input":
return task_input
if not path.startswith("input."):
return path
value = task_input
for part in path.split(".")[1:]:
if isinstance(value, dict) and part in value:
value = value[part]
elif isinstance(value, list) and part.isdigit() and int(part) < len(value):
value = value[int(part)]
else:
raise ValueError(f"local.env_map path not found: {path}")
return value
class LocalReconciler:
"""Local-mode rollout reconciler."""
def __init__(
self,
api: AgentLightningAsyncClient,
config: DictConfig,
) -> None:
assert config.runner_type == "local"
self._api = api
self._config = config
self._runner_config = config.local_runner
self._pool_size = int(self._runner_config.maximum_size)
self._tick_interval = float(self._runner_config.poll_interval)
self._rid_to_proc: dict[str, Proc] = {}
self._stop = asyncio.Event()
async def run(self) -> None:
log.info("LocalReconciler starting", pool_size=self._pool_size, tick=self._tick_interval)
try:
await self._reconcile_loop()
finally:
await self._shutdown()
def stop(self) -> None:
self._stop.set()
async def _reconcile_loop(self) -> None:
while not self._stop.is_set():
try:
await self._reconcile_once()
except Exception:
log.exception("Local reconcile error")
try:
await asyncio.wait_for(self._stop.wait(), timeout=self._tick_interval)
break
except TimeoutError:
pass
async def _reconcile_once(self) -> None:
params = httpx.QueryParams()
params = params.add("state_in", RolloutState.QUEUING.value)
params = params.add("state_in", RolloutState.RUNNING.value)
params = params.add("limit", 50)
response = await self._api.get("/api/rollouts", params=params)
response.raise_for_status()
rollouts = [Rollout.model_validate(item) for item in response.json()]
rollouts_by_id = {rollout.rollout_id: rollout for rollout in rollouts}
live_count = sum(1 for item in self._rid_to_proc.values() if item.proc.returncode is None)
for rollout in rollouts:
item = self._rid_to_proc.get(rollout.rollout_id)
if item is None:
if rollout.status.state == RolloutState.QUEUING and live_count < self._pool_size:
if await self._spawn_for(rollout):
live_count += 1
elif rollout.status.state == RolloutState.RUNNING:
await self._patch(rollout.rollout_id, RolloutState.FAILED, "local subprocess is not running")
continue
if item.proc.returncode is None:
if rollout.status.state == RolloutState.QUEUING:
await self._patch(rollout.rollout_id, RolloutState.RUNNING, last_attempt_id=item.attempt_id)
continue
await self._finish_proc(rollout, item)
now = time.monotonic()
for rollout_id, item in list(self._rid_to_proc.items()):
if item.proc.returncode is not None:
continue
rollout = rollouts_by_id.get(rollout_id)
timeout = float(rollout.config.timeout_seconds) if rollout and rollout.config.timeout_seconds else None
if (
timeout is not None
and (now - item.spawned_at) > timeout
and await self._kill_process_group(rollout_id, item)
):
await self._patch(rollout_id, RolloutState.FAILED, "local subprocess timed out")
async def _finish_proc(self, rollout: Rollout, item: Proc) -> bool:
if rollout.status.state == RolloutState.QUEUING:
patched = await self._patch(rollout.rollout_id, RolloutState.RUNNING, last_attempt_id=item.attempt_id)
if not patched:
return False
if item.proc.returncode == 0:
return await self._patch(rollout.rollout_id, RolloutState.SUCCEEDED, last_attempt_id=item.attempt_id)
return await self._patch(
rollout.rollout_id,
RolloutState.FAILED,
f"subprocess exited with code {item.proc.returncode}",
)
async def _kill_process_group(self, rollout_id: str, item: Proc) -> bool:
"""SIGKILL the worker process group and wait for exit."""
if item.proc.returncode is not None:
return True
if not item.killed:
with contextlib.suppress(ProcessLookupError):
os.killpg(item.proc.pid, signal.SIGKILL)
item.killed = True
log.info("SIGKILL sent to subprocess group", rollout_id=rollout_id, pid=item.proc.pid)
try:
await asyncio.wait_for(item.proc.wait(), timeout=_SHUTDOWN_WAIT_TIMEOUT)
return True
except TimeoutError:
log.warning("Subprocess did not exit after SIGKILL within 5s", rollout_id=rollout_id, pid=item.proc.pid)
return False
async def _spawn_for(self, rollout: Rollout) -> bool:
"""Spawn one local subprocess for a rollout."""
try:
attempt_id = DEFAULT_ATTEMPT_ID
if rollout.config.local is None or not rollout.config.local.agent_class:
raise ValueError("invalid rollout config: missing config.local.agent_class")
agent_class = rollout.config.local.agent_class
mode = "train" if rollout.is_train else "val"
env = {
**os.environ,
"AGL_KEY": str(self._config.agl_server.key or ""),
"AGL_OPENAI_BASE_URL": (
f"{self._config.agl_server.url}/proxy/rollout/{rollout.rollout_id}"
f"/attempt/{attempt_id}/mode/{mode}/openai/v1"
),
"AGL_EVENT_URL": (
f"{self._config.agl_server.url}/api/rollouts/{rollout.rollout_id}/attempt/{attempt_id}/events"
),
}
env.update(_build_env_from_map(rollout.input, rollout.config.local.env_map))
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-c",
(
"import sys; "
"from agentlightning.controller.local_reconciler import _run_local_reconciler_worker; "
"sys.exit(_run_local_reconciler_worker(sys.argv[1]))"
),
agent_class,
stdin=asyncio.subprocess.DEVNULL,
stdout=None,
stderr=None,
env=env,
start_new_session=True,
)
except Exception as e:
log.exception("Spawn failed", rollout_id=rollout.rollout_id)
await self._patch(rollout.rollout_id, RolloutState.FAILED, f"local subprocess spawn failed: {e}")
return False
self._rid_to_proc[rollout.rollout_id] = Proc(
attempt_id=attempt_id,
proc=proc,
spawned_at=time.monotonic(),
)
await self._patch(rollout.rollout_id, RolloutState.RUNNING, last_attempt_id=attempt_id)
log.info("Spawned rollout subprocess", rollout_id=rollout.rollout_id, attempt_id=attempt_id, pid=proc.pid)
return True
async def _shutdown(self) -> None:
"""Kill live subprocesses and mark them failed."""
try:
await self._reconcile_once()
except Exception:
log.exception("Final reconcile during shutdown failed")
for rollout_id, item in list(self._rid_to_proc.items()):
if item.proc.returncode is None and await self._kill_process_group(rollout_id, item):
await self._patch(rollout_id, RolloutState.FAILED, "local controller shutdown")
async def _patch(
self,
rollout_id: str,
state: RolloutState,
error_message: str | None = None,
*,
last_attempt_id: str | None = None,
) -> bool:
status = RolloutStatusPatch(state=state)
if error_message is not None:
status.error_message = error_message
if last_attempt_id is not None:
status.last_attempt_id = last_attempt_id
patch = RolloutPatch(status=status)
try:
response = await self._api.patch(
f"/api/rollouts/{rollout_id}",
json=patch.model_dump(mode="json", exclude_unset=True),
)
response.raise_for_status()
return True
except Exception as e:
log.warning("Failed to patch rollout", rollout_id=rollout_id, error=str(e))
return False
-43
View File
@@ -1,43 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Convenient helpers for creating spans / traces.
All emitters operate in two modes, switchable via the `propagate` parameter.
The emitters first [`SpanCreationRequest`][agentlightning.SpanCreationRequest] object, then:
1. When `propagate` is True, this creation request will be propagated to the active tracer
and a [`Span`][agentlightning.Span] instance will be created (possibly deferred).
2. When `propagate` is False, the creation request will be returned directly. Useful for cases
when you don't have a tracer but you want to create a creation request for later use.
"""
from .annotation import emit_annotation, operation
from .exception import emit_exception
from .message import emit_message, get_message_value
from .object import emit_object, get_object_value
from .reward import (
emit_reward,
find_final_reward,
find_reward_spans,
get_reward_value,
get_rewards_from_span,
is_reward_span,
reward,
)
__all__ = [
"reward",
"operation",
"emit_reward",
"get_reward_value",
"get_rewards_from_span",
"is_reward_span",
"find_reward_spans",
"find_final_reward",
"emit_message",
"emit_object",
"emit_exception",
"emit_annotation",
"get_message_value",
"get_object_value",
]
-370
View File
@@ -1,370 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Helpers for emitting annotation/operation spans."""
import asyncio
import functools
import inspect
import logging
from types import TracebackType
from typing import (
Any,
Callable,
ContextManager,
Dict,
Optional,
Tuple,
Type,
TypeVar,
Union,
cast,
overload,
)
from agentlightning.semconv import AGL_ANNOTATION, AGL_OPERATION, LightningSpanAttributes
from agentlightning.tracer.base import get_active_tracer
from agentlightning.tracer.dummy import DummyTracer
from agentlightning.types import SpanCoreFields, SpanRecordingContext, TraceStatus
from agentlightning.utils.otel import check_attributes_sanity, flatten_attributes, sanitize_attributes
_FnType = TypeVar("_FnType", bound=Callable[..., Any])
logger = logging.getLogger(__name__)
def emit_annotation(annotation: Dict[str, Any], propagate: bool = True) -> SpanCoreFields:
"""Emit a new annotation span.
This is the underlying implementation of [`emit_reward`][agentlightning.emit_reward].
Annotation spans are used to annotate a specific event or a part of rollout.
See [semconv][agentlightning.semconv] for conventional annotation keys in Agent-lightning.
If annotations contain nested dicts, they will be flattened before emitting.
Complex objects will lead to emitting failures.
Args:
annotation: Dictionary containing annotation key-value pairs.
Representatives are rewards, tags, and metadata.
propagate: Whether to propagate the span to tracers automatically.
"""
annotation_attributes = flatten_attributes(annotation, expand_leaf_lists=False)
check_attributes_sanity(annotation_attributes)
sanitized_attributes = sanitize_attributes(annotation_attributes)
logger.debug("Emitting annotation span with keys %s", sanitized_attributes.keys())
if propagate:
tracer = get_active_tracer()
if tracer is None:
raise RuntimeError("No active tracer found. Cannot emit annotation span.")
else:
tracer = DummyTracer()
return tracer.create_span(
name=AGL_ANNOTATION,
attributes=sanitized_attributes,
status=TraceStatus(status_code="OK"),
)
class OperationContext:
"""Context manager and decorator for tracing operations.
This class manages a tracer-backed span for a logical unit of work. It can be
used either:
* As a decorator, in which case inputs and outputs are inferred
automatically from the wrapped function's signature.
* As a context manager, in which case inputs and outputs can be recorded
explicitly via [`set_input`][agentlightning.emitter.annotation.OperationContext.set_input]
and [`set_output`][agentlightning.emitter.annotation.OperationContext.set_output].
Attributes:
name: Human-readable span name.
initial_attributes: Attributes applied when the span is created.
tracer: Tracer implementation used to create spans.
"""
def __init__(self, name: str, attributes: Dict[str, Any], propagate: bool = True) -> None:
"""Initialize a new operation context.
Args:
name: Human-readable name of the span.
attributes: Initial attributes attached to the span. Values are
JSON-serialized where necessary.
propagate: Whether the span should be sent to active exporters.
"""
self.name = name
self.initial_attributes = flatten_attributes(attributes, expand_leaf_lists=False)
self.propagate = propagate
if propagate:
tracer = get_active_tracer()
if tracer is None:
raise RuntimeError("No active tracer found. Cannot trace operation spans.")
self.tracer = tracer
else:
self.tracer = DummyTracer()
self._ctx_manager: Optional[ContextManager[SpanRecordingContext]] = None
self._recording_context: Optional[SpanRecordingContext] = None
self._span: Optional[SpanCoreFields] = None
def __enter__(self) -> "OperationContext":
"""Enter the context manager and start a new span.
Returns:
The current :class:`OperationContext` instance with an active span.
"""
sanitized_attrs = sanitize_attributes(self.initial_attributes)
self._ctx_manager = self.tracer.operation_context(self.name, attributes=sanitized_attrs)
recording_context = self._ctx_manager.__enter__()
self._recording_context = recording_context
return self
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
"""Exit the context manager and finish the span."""
if self._ctx_manager:
self._ctx_manager.__exit__(exc_type, exc_val, exc_tb)
if self._recording_context:
self._span = self._recording_context.get_recorded_span()
self._ctx_manager = None
self._recording_context = None
def span(self) -> SpanCoreFields:
"""Get the span that was created by this context manager."""
if self._span is None:
raise RuntimeError("Span is not ready yet.")
return self._span
def set_input(self, *args: Any, **kwargs: Any) -> None:
"""Record input arguments on the current span.
Positional arguments are stored under the `input.args.<index>` attributes,
and keyword arguments are stored under `input.<name>` attributes.
This is intended for use inside a `with operation(...) as op` block.
Args:
*args: Positional arguments to record.
**kwargs: Keyword arguments to record.
"""
if not self._recording_context:
raise RuntimeError("No recording context found. Cannot set input.")
prefix = LightningSpanAttributes.OPERATION_INPUT.value
attributes: Dict[str, Any] = {}
if args:
for idx, value in enumerate(args):
flattened = flatten_attributes({str(idx): value})
for nested_key, nested_value in flattened.items():
attributes[f"{prefix}.args.{nested_key}"] = nested_value
if kwargs:
for key, value in kwargs.items():
flattened = flatten_attributes({key: value})
for nested_key, nested_value in flattened.items():
attributes[f"{prefix}.{nested_key}"] = nested_value
if attributes:
self._recording_context.record_attributes(sanitize_attributes(attributes))
def set_output(self, output: Any) -> None:
"""Record the output value on the current span.
This is intended for use inside a `with operation(...) as op` block.
Args:
output: The output value to record.
"""
if not self._recording_context:
raise RuntimeError("No recording context found. Cannot set output.")
flattened = flatten_attributes({LightningSpanAttributes.OPERATION_OUTPUT.value: output})
self._recording_context.record_attributes(sanitize_attributes(flattened))
def __call__(self, fn: _FnType) -> _FnType:
"""Wrap a callable so its execution is traced in a span.
When used as a decorator, a new span is created for each call to
the wrapped function. The bound arguments are recorded as input
attributes, the return value is recorded as an output attribute,
and any exception is recorded and marks the span as an error.
Args:
fn: The function or coroutine function to wrap.
Returns:
The wrapped callable.
"""
function_name = fn.__name__
sig = inspect.signature(fn)
sanitized_init_attrs = sanitize_attributes(
{LightningSpanAttributes.OPERATION_NAME.value: function_name, **self.initial_attributes}
)
def _record_auto_inputs(
recording_ctx: SpanRecordingContext, args: Tuple[Any, ...], kwargs: Dict[str, Any]
) -> None:
"""Bind arguments to signature and log them on the span."""
attributes: Dict[str, Any] = {}
try:
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for name, value in bound.arguments.items():
parameter = sig.parameters.get(name)
if parameter and parameter.kind is inspect.Parameter.VAR_POSITIONAL:
attr_prefix = f"{LightningSpanAttributes.OPERATION_INPUT.value}.{name}"
for idx, item in enumerate(value):
flattened = flatten_attributes({str(idx): item})
for nested_key, nested_value in flattened.items():
attributes[f"{attr_prefix}.{nested_key}"] = nested_value
else:
flattened = flatten_attributes({name: value})
for nested_key, nested_value in flattened.items():
attributes[f"{LightningSpanAttributes.OPERATION_INPUT.value}.{nested_key}"] = nested_value
except Exception:
if args:
for idx, value in enumerate(args):
flattened = flatten_attributes({str(idx): value})
for nested_key, nested_value in flattened.items():
attributes[f"{LightningSpanAttributes.OPERATION_INPUT.value}.args.{nested_key}"] = (
nested_value
)
if kwargs:
flattened = flatten_attributes({"kwargs": kwargs})
for nested_key, nested_value in flattened.items():
attributes[f"{LightningSpanAttributes.OPERATION_INPUT.value}.{nested_key}"] = nested_value
if attributes:
recording_ctx.record_attributes(sanitize_attributes(attributes))
def _record_auto_outputs(recording_ctx: SpanRecordingContext, result: Any) -> None:
"""Record the output value on the span."""
flattened = flatten_attributes({LightningSpanAttributes.OPERATION_OUTPUT.value: result})
recording_ctx.record_attributes(sanitize_attributes(flattened))
if asyncio.iscoroutinefunction(fn) or inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
"""Async wrapper that traces the wrapped coroutine."""
with self.tracer.operation_context(self.name, attributes=sanitized_init_attrs) as recording_ctx:
_record_auto_inputs(recording_ctx, args, kwargs)
result = await fn(*args, **kwargs)
_record_auto_outputs(recording_ctx, result)
return result
return cast(_FnType, async_wrapper)
else:
@functools.wraps(fn)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
"""Sync wrapper that traces the wrapped callable."""
with self.tracer.operation_context(self.name, attributes=sanitized_init_attrs) as recording_ctx:
_record_auto_inputs(recording_ctx, args, kwargs)
result = fn(*args, **kwargs)
_record_auto_outputs(recording_ctx, result)
return result
return cast(_FnType, sync_wrapper)
@overload
def operation(
fn: _FnType, *, propagate: bool = True, name: Optional[str] = None, **additional_attributes: Any
) -> _FnType: ...
@overload
def operation(
*, propagate: bool = True, name: Optional[str] = None, **additional_attributes: Any
) -> OperationContext: ...
@overload
def operation(fn: _FnType, *, name: Optional[str] = None, **additional_attributes: Any) -> _FnType: ...
@overload
def operation(*, name: Optional[str] = None, **additional_attributes: Any) -> OperationContext: ...
@overload
def operation(fn: _FnType, **additional_attributes: Any) -> _FnType: ...
@overload
def operation(**additional_attributes: Any) -> OperationContext: ...
def operation(
fn: Optional[_FnType] = None,
*,
propagate: bool = True,
name: Optional[str] = None,
**additional_attributes: Any,
) -> Union[_FnType, OperationContext]:
"""Entry point for tracking operations.
This helper can be used either as a decorator or as a context manager.
The span name is fixed to [`AGL_OPERATION`][agentlightning.semconv.AGL_OPERATION];
custom span names are not supported. Any keyword arguments are recorded as span attributes.
Usage as a decorator:
```python
@operation
def func(...):
...
@operation(category="compute")
def func(...):
...
```
Usage as a context manager:
```python
with operation(user_id=123) as op:
op.set_input(data=data)
# ... do work ...
op.set_output(result)
```
Args:
fn: When used as `@operation`, this is the wrapped function.
When used as `operation(**attrs)`, this should be omitted (or
left as `None`) and only keyword attributes are provided.
propagate: Whether spans should use the active span processor. When False,
spans will stay local and not be exported.
name: Optional alias that populates
[`LightningSpanAttributes.OPERATION_NAME`][agentlightning.semconv.LightningSpanAttributes.OPERATION_NAME]
when `additional_attributes` does not already define it.
**additional_attributes: Additional span attributes to attach at
creation time.
Returns:
Either a wrapped callable (when used as a decorator) or an
[`OperationContext`][agentlightning.emitter.annotation.OperationContext]
(when used as a context manager factory).
"""
if name is not None:
if LightningSpanAttributes.OPERATION_NAME.value in additional_attributes:
raise ValueError("Cannot specify both `name` and `additional_attributes.operation_name`.")
additional_attributes[LightningSpanAttributes.OPERATION_NAME.value] = name
# Case 1: Used as @operation (bare decorator or with attributes)
if callable(fn):
# Create context with fixed name, then immediately wrap the function
return OperationContext(AGL_OPERATION, additional_attributes, propagate=propagate)(fn)
# Case 2: Used as operation(...) / with operation(...)
# Custom span names are intentionally not supported; use AGL_OPERATION.
if fn is not None:
raise ValueError("Custom span names are intentionally not supported when used as a context manager.")
return OperationContext(AGL_OPERATION, additional_attributes, propagate=propagate)
-54
View File
@@ -1,54 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from typing import Any, Dict, Optional
from agentlightning.semconv import AGL_EXCEPTION
from agentlightning.tracer.base import get_active_tracer
from agentlightning.tracer.dummy import DummyTracer
from agentlightning.types import TraceStatus
from agentlightning.utils.otel import flatten_attributes, format_exception_attributes, sanitize_attributes
logger = logging.getLogger(__name__)
def emit_exception(
exception: BaseException, attributes: Optional[Dict[str, Any]] = None, propagate: bool = True
) -> None:
"""Record an exception with OpenTelemetry metadata.
Classic OpenTelemetry records exceptions in a dedicated logging service.
We simplify the model and use trace spans to record exceptions as well.
Args:
exception: Raised exception instance to serialize into telemetry attributes.
attributes: Additional attributes to attach to the exception span.
propagate: Whether to propagate the span to exporters automatically.
!!! note
The helper validates its input. If a non-exception value is provided,
a TypeError is raised to indicate a programming mistake.
"""
if not isinstance(exception, BaseException): # type: ignore
raise TypeError(f"Expected a BaseException instance, got: {type(exception)}.")
span_attributes = format_exception_attributes(exception)
if attributes:
flattened = flatten_attributes(attributes, expand_leaf_lists=False)
span_attributes.update(sanitize_attributes(flattened))
logger.debug("Emitting exception span for %s", type(exception).__name__)
if propagate:
tracer = get_active_tracer()
if tracer is None:
raise RuntimeError("No active tracer found. Cannot emit exception span.")
else:
tracer = DummyTracer()
tracer.create_span(
AGL_EXCEPTION,
attributes=span_attributes,
# The exception span is successful by itself.
status=TraceStatus(status_code="OK"),
)
-61
View File
@@ -1,61 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from typing import Any, Dict, Optional
from agentlightning.semconv import AGL_MESSAGE, LightningSpanAttributes
from agentlightning.tracer.base import get_active_tracer
from agentlightning.tracer.dummy import DummyTracer
from agentlightning.types import Attributes, SpanLike
from agentlightning.utils.otel import flatten_attributes, sanitize_attributes
logger = logging.getLogger(__name__)
def emit_message(message: str, attributes: Optional[Dict[str, Any]] = None, propagate: bool = True) -> None:
"""Emit a textual message as an OpenTelemetry span.
Commonly used for sending debugging and logging messages.
Args:
message: Human readable message to attach as a span attribute.
attributes: Additional attributes to attach to the message span.
propagate: Whether to propagate the span to exporters automatically.
!!! 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
raise TypeError(f"Message must be a string or list of strings, got: {type(message)}.")
if propagate:
tracer = get_active_tracer()
if tracer is None:
raise RuntimeError("No active tracer found. Cannot emit message span.")
else:
tracer = DummyTracer()
span_attributes: Attributes = {LightningSpanAttributes.MESSAGE_BODY.value: message}
if attributes:
flattened = flatten_attributes(attributes, expand_leaf_lists=False)
span_attributes.update(sanitize_attributes(flattened))
logger.debug("Emitting message span with message: %s", message)
tracer.create_span(
AGL_MESSAGE,
attributes=span_attributes,
)
def get_message_value(span: SpanLike) -> Optional[str]:
"""Extract the message string from a message span.
Args:
span: Span-like object to extract the message from.
"""
span_attributes = span.attributes or {}
if LightningSpanAttributes.MESSAGE_BODY.value not in span_attributes:
return None
message = span_attributes[LightningSpanAttributes.MESSAGE_BODY.value]
if isinstance(message, str):
return message
raise TypeError(f"Message must be a string, got: {type(message)}.")
-117
View File
@@ -1,117 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import base64
import json
import logging
from typing import Any, Dict, Optional
from agentlightning.semconv import AGL_OBJECT, LightningSpanAttributes
from agentlightning.tracer.base import get_active_tracer
from agentlightning.tracer.dummy import DummyTracer
from agentlightning.types import SpanCoreFields, SpanLike, TraceStatus
from agentlightning.utils.otel import flatten_attributes, full_qualified_name, sanitize_attributes
logger = logging.getLogger(__name__)
def emit_object(object: Any, attributes: Optional[Dict[str, Any]] = None, propagate: bool = True) -> SpanCoreFields:
"""Emit an object's serialized representation as an OpenTelemetry span.
Args:
object: Data structure to encode as JSON and attach to the span payload.
attributes: Additional attributes to attach to the object span.
propagate: Whether to propagate the span to exporters automatically.
!!! note
The payload must be JSON serializable. Non-serializable objects will lead to a RuntimeError.
"""
span_attributes = encode_object(object)
if attributes:
flattened = flatten_attributes(attributes, expand_leaf_lists=False)
span_attributes.update(sanitize_attributes(flattened))
attr_length = 0
if LightningSpanAttributes.OBJECT_JSON.value in span_attributes:
attr_length = len(span_attributes[LightningSpanAttributes.OBJECT_JSON.value])
elif LightningSpanAttributes.OBJECT_LITERAL.value in span_attributes:
attr_length = len(span_attributes[LightningSpanAttributes.OBJECT_LITERAL.value])
logger.debug("Emitting object span with payload size %d characters", attr_length)
if propagate:
tracer = get_active_tracer()
if tracer is None:
raise RuntimeError("No active tracer found. Cannot emit object span.")
else:
# Do not actually propagate to any store or tracer backend.
tracer = DummyTracer()
return tracer.create_span(
name=AGL_OBJECT,
attributes=span_attributes,
status=TraceStatus(status_code="OK"),
)
def encode_object(object: Any) -> Dict[str, Any]:
"""Encode an object as span attributes.
Args:
object: Data structure to encode as JSON.
"""
span_attributes = {}
if isinstance(object, (str, int, float, bool)):
span_attributes = {
LightningSpanAttributes.OBJECT_TYPE.value: type(object).__name__,
LightningSpanAttributes.OBJECT_LITERAL.value: str(object),
}
elif isinstance(object, bytes):
b64_encoded = base64.b64encode(object).decode("utf-8")
span_attributes = {
LightningSpanAttributes.OBJECT_TYPE.value: "bytes",
LightningSpanAttributes.OBJECT_LITERAL.value: b64_encoded,
}
else:
try:
serialized = json.dumps(object)
except (TypeError, ValueError) as exc:
raise RuntimeError(f"Object must be JSON serializable, got: {type(object)}.") from exc
span_attributes = {
LightningSpanAttributes.OBJECT_TYPE.value: full_qualified_name(type(object)), # type: ignore
LightningSpanAttributes.OBJECT_JSON.value: serialized,
}
return span_attributes
def get_object_value(span: SpanLike) -> Any:
"""Extract the object payload from an object span.
Args:
span: Span object produced by Agent Lightning emitters.
"""
attributes = span.attributes or {}
if LightningSpanAttributes.OBJECT_JSON.value in attributes:
serialized = attributes[LightningSpanAttributes.OBJECT_JSON.value]
try:
return json.loads(serialized) # type: ignore
except (TypeError, ValueError) as exc:
raise RuntimeError("Failed to deserialize object JSON from span.") from exc
elif LightningSpanAttributes.OBJECT_LITERAL.value in attributes:
literal = attributes[LightningSpanAttributes.OBJECT_LITERAL.value]
obj_type = attributes.get(LightningSpanAttributes.OBJECT_TYPE.value, "str")
if obj_type == "str":
return literal
elif obj_type == "int":
# Let it raise errors if there are any
return int(literal) # type: ignore
elif obj_type == "float":
return float(literal) # type: ignore
elif obj_type == "bool":
return literal.lower() == "true" # type: ignore
elif obj_type == "bytes":
return base64.b64decode(literal.encode("utf-8")) # type: ignore
else:
raise RuntimeError(f"Unsupported object type for literal deserialization: {obj_type}")
else:
return None
-316
View File
@@ -1,316 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Helpers for emitting reward spans and integrating with AgentOps telemetry."""
import asyncio
import inspect
import json
import logging
import warnings
from typing import (
Any,
Callable,
Dict,
List,
Literal,
Optional,
Sequence,
TypedDict,
TypeVar,
cast,
)
from pydantic import TypeAdapter
from agentlightning.semconv import AGL_ANNOTATION, LightningSpanAttributes, RewardPydanticModel
from agentlightning.types import SpanCoreFields, SpanLike
from agentlightning.utils.otel import filter_and_unflatten_attributes
from .annotation import emit_annotation
logger = logging.getLogger(__name__)
__all__ = [
"reward",
"emit_reward",
"get_reward_value",
"get_rewards_from_span",
"is_reward_span",
"find_reward_spans",
"find_final_reward",
]
class RewardDimension(TypedDict):
"""Type representing a single dimension in a multi-dimensional reward."""
name: str
value: float
class _RewardSpanData(TypedDict):
type: Literal["reward"]
value: Optional[float]
_FnType = TypeVar("_FnType", bound=Callable[..., Any])
def _agentops_initialized() -> bool:
"""Return `True` when the AgentOps client has been configured."""
import agentops
return agentops.get_client().initialized
def reward(fn: _FnType) -> _FnType:
"""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.
"""
from agentops.sdk.decorators import operation
def wrap_result(result: Optional[float]) -> _RewardSpanData:
"""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
warnings.warn(f"Reward is ignored because it is not a number: {result}")
return {"type": "reward", "value": None}
return {"type": "reward", "value": float(result)}
# Check if the function is async
is_async = asyncio.iscoroutinefunction(fn) or inspect.iscoroutinefunction(fn)
if is_async:
async def wrapper_async(*args: Any, **kwargs: Any) -> Any:
if not _agentops_initialized():
# Track the reward without AgentOps
result = await fn(*args, **kwargs)
emit_reward(cast(float, result))
return result
result: Optional[float] = None
@operation
async def agentops_reward_operation() -> _RewardSpanData:
# The reward function we are interested in tracing
# It takes zero inputs and return a formatted dict
nonlocal result
result = await fn(*args, **kwargs)
return wrap_result(result)
await agentops_reward_operation()
return result
return wrapper_async # type: ignore
else:
def wrapper(*args: Any, **kwargs: Any) -> Any:
if not _agentops_initialized():
# Track the reward without AgentOps
result = fn(*args, **kwargs)
emit_reward(cast(float, result))
return result
result: Optional[float] = None
@operation
def agentops_reward_operation() -> _RewardSpanData:
nonlocal result
result = fn(*args, **kwargs)
return wrap_result(result)
agentops_reward_operation()
return result
return wrapper # type: ignore
def emit_reward(
reward: float | Dict[str, Any],
*,
primary_key: str | None = None,
attributes: Dict[str, Any] | None = None,
propagate: bool = True,
) -> SpanCoreFields:
"""Emit a reward value as an OpenTelemetry span.
Examples:
Emit a single-dimensional reward:
>>> emit_reward(1.0)
Emit multi-dimensional rewards:
>>> emit_reward({"task_completion": 1.0, "efficiency": 0.8}, primary_key="task_completion")
Emit a reward with additional attributes (for example linking to another response span):
>>> from agentlightning.utils.otel import make_link_attributes
>>> emit_reward(0.5, attributes=make_link_attributes({"gen_ai.response.id": "response-123"}))
Or adding tags onto the reward span:
>>> from agentlightning.utils.otel import make_tag_attributes
>>> emit_reward(0.7, attributes=make_tag_attributes(["fast", "reliable"]))
Args:
reward: Numeric reward to record. Integers and booleans are converted to
floating point numbers for consistency.
Use a dictionary to represent a multi-dimensional reward.
attributes: Other optional span attributes.
propagate: Whether to propagate the span to exporters automatically.
Returns:
Span core fields capturing the recorded reward.
"""
logger.debug(f"Emitting reward: {reward}")
reward_dimensions: List[RewardDimension] = []
if isinstance(reward, dict):
reward_dict: Dict[str, float] = {}
for k, v in reward.items():
if isinstance(v, (int, bool)):
reward_dict[k] = float(v)
elif isinstance(v, float):
reward_dict[k] = v
else:
raise ValueError(f"Reward value must be a number, got: {type(v)} for key {k}")
if primary_key is None:
raise ValueError("When emitting a multi-dimensional reward as a dict, primary_key must be provided.")
if primary_key not in reward_dict:
raise ValueError(f"Primary key '{primary_key}' not found in reward dict keys: {list(reward_dict.keys())}")
reward_dimensions.append(RewardDimension(name=primary_key, value=reward_dict[primary_key]))
for k, v in reward_dict.items():
if k != primary_key:
reward_dimensions.append(RewardDimension(name=k, value=v))
else:
if isinstance(reward, (int, bool)):
reward = float(reward)
elif not isinstance(reward, float): # pyright: ignore[reportUnnecessaryIsInstance]
raise TypeError(f"Reward must be a number, got: {type(reward)}")
reward_dimensions.append(RewardDimension(name="primary", value=reward))
return emit_annotation(
{LightningSpanAttributes.REWARD.value: reward_dimensions, **(attributes or {})}, propagate=propagate
)
def get_reward_value(span: SpanLike) -> Optional[float]:
"""Extract the reward value from a span, if available.
Args:
span: Span object produced by AgentOps or Agent Lightning emitters.
Returns:
The primary reward encoded in the span or `None` when the span does not represent a reward.
"""
# v0.3+ emit reward format
reward_list = get_rewards_from_span(span)
if reward_list:
# Reward list is ordered and the first element is the primary reward
return reward_list[0].value
for key in [
"agentops.task.output", # newer versions of agentops
"agentops.entity.output",
]:
reward_dict: Dict[str, Any] | None = None
if span.attributes:
output = span.attributes.get(key)
if output:
if isinstance(output, dict):
reward_dict = cast(Dict[str, Any], output)
elif isinstance(output, str):
try:
reward_dict = cast(Dict[str, Any], json.loads(output))
except json.JSONDecodeError:
reward_dict = None
if reward_dict and reward_dict.get("type") == "reward":
reward_value = reward_dict.get("value", None)
if reward_value is None:
return None
if not isinstance(reward_value, float):
logger.error(f"Reward is not a number, got: {type(reward_value)}. This may cause undefined behaviors.")
logger.warning(
f"Extracted reward {reward_value} from AgentOps. This format is deprecated, please migrate to using `emit_reward`."
)
return cast(float, reward_value)
# v0.2 emit reward format
if span.name == AGL_ANNOTATION and span.attributes:
reward_value = span.attributes.get("reward", None)
if reward_value is None:
return None
if not isinstance(reward_value, float):
logger.error(f"Reward is not a number, got: {type(reward_value)}. This may cause undefined behaviors.")
logger.warning(
f"Extracted reward {reward_value} from a legacy version of reward span. You might have inconsistent agent-lightning versions."
)
return cast(float, reward_value)
return None
def get_rewards_from_span(span: SpanLike) -> List[RewardPydanticModel]:
"""Extract the reward as a list from a span, if available.
Args:
span: Span object produced by AgentOps or Agent Lightning emitters.
Returns:
A list of reward dimensions encoded in the span or an empty list when the span does not represent a reward.
"""
if span.attributes and any(key.startswith(LightningSpanAttributes.REWARD.value) for key in span.attributes):
reward_attr = filter_and_unflatten_attributes(
cast(Any, span.attributes or {}), LightningSpanAttributes.REWARD.value
)
recovered_rewards = TypeAdapter(List[RewardPydanticModel]).validate_python(reward_attr)
return recovered_rewards
else:
return []
def is_reward_span(span: SpanLike) -> bool:
"""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]:
"""Return all reward spans in the provided sequence.
Args:
spans: Sequence containing [`ReadableSpan`](https://opentelemetry.io/docs/concepts/signals/traces/) objects or mocked span-like values.
Returns:
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]:
"""Return the last reward value present in the provided spans.
Args:
spans: Sequence containing [`ReadableSpan`](https://opentelemetry.io/docs/concepts/signals/traces/) objects or mocked span-like values.
Returns:
Reward value from the latest reward span, or `None` when none are found.
"""
for span in reversed(spans):
reward = get_reward_value(span)
if reward is not None:
return reward
return None
-156
View File
@@ -1,156 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Environment variable managements."""
from __future__ import annotations
import os
from enum import Enum
from typing import overload
__all__ = [
"LightningEnvVar",
"resolve_bool_env_var",
"resolve_int_env_var",
"resolve_str_env_var",
]
class LightningEnvVar(Enum):
"""Environment variables for Agent Lightning."""
AGL_EMITTER_DEBUG = "AGL_EMITTER_DEBUG"
"""Enable debug logging for the emitter."""
AGL_MANAGED_STORE = "AGL_MANAGED_STORE"
"""If yes, the [`ExecutionStrategy`][agentlightning.ExecutionStrategy]
constructs LightningStore wrappers automatically. When `False` the provided
`store` is passed directly to the bundles, allowing callers to manage
store wrappers manually."""
AGL_CURRENT_ROLE = "AGL_CURRENT_ROLE"
"""Which side(s) to run in this process. Used in
[`ClientServerExecutionStrategy`][agentlightning.ClientServerExecutionStrategy]."""
AGL_SERVER_HOST = "AGL_SERVER_HOST"
"""Interface the [`LightningStoreServer`][agentlightning.LightningStoreServer]
binds to when running the algorithm bundle locally."""
AGL_SERVER_PORT = "AGL_SERVER_PORT"
"""Port the [`LightningStoreServer`][agentlightning.LightningStoreServer] listens to."""
_TRUTHY_VALUES = {"1", "true", "yes", "on"}
_FALSY_VALUES = {"0", "false", "no", "off"}
@overload
def resolve_bool_env_var(env_var: LightningEnvVar, override: bool, fallback: bool) -> bool: ...
@overload
def resolve_bool_env_var(env_var: LightningEnvVar, *, fallback: bool) -> bool: ...
@overload
def resolve_bool_env_var(
env_var: LightningEnvVar, override: bool | None = None, fallback: bool | None = None
) -> bool | None: ...
def resolve_bool_env_var(
env_var: LightningEnvVar, override: bool | None = None, fallback: bool | None = None
) -> bool | None:
"""Resolve a boolean environment variable.
Args:
env_var: The environment variable to resolve.
override: Optional override supplied by the caller.
fallback: Default value if the environment variable is not set.
"""
if override is not None:
return override
env_value = os.getenv(env_var.value)
if env_value is None:
return fallback
normalized = env_value.strip().lower()
if normalized in _TRUTHY_VALUES:
return True
if normalized in _FALSY_VALUES:
return False
raise ValueError(f"{env_var.value} must be one of {_TRUTHY_VALUES} or {_FALSY_VALUES}")
@overload
def resolve_int_env_var(env_var: LightningEnvVar, override: int, fallback: int) -> int: ...
@overload
def resolve_int_env_var(env_var: LightningEnvVar, *, fallback: int) -> int: ...
@overload
def resolve_int_env_var(
env_var: LightningEnvVar, override: int | None = None, fallback: int | None = None
) -> int | None: ...
def resolve_int_env_var(
env_var: LightningEnvVar, override: int | None = None, fallback: int | None = None
) -> int | None:
"""Resolve an integer environment variable.
Args:
env_var: The environment variable to resolve.
override: Optional override supplied by the caller.
fallback: Default value if the environment variable is not set.
"""
if override is not None:
return override
env_value = os.getenv(env_var.value)
if env_value is None:
return fallback
try:
return int(env_value)
except ValueError:
raise ValueError(f"{env_var.value} must be an integer")
@overload
def resolve_str_env_var(env_var: LightningEnvVar, override: str, fallback: str) -> str: ...
@overload
def resolve_str_env_var(env_var: LightningEnvVar, *, fallback: str) -> str: ...
@overload
def resolve_str_env_var(
env_var: LightningEnvVar, override: str | None = None, fallback: str | None = None
) -> str | None: ...
def resolve_str_env_var(
env_var: LightningEnvVar, override: str | None = None, fallback: str | None = None
) -> str | None:
"""Resolve a string environment variable.
Args:
env_var: The environment variable to resolve.
override: Optional override supplied by the caller.
fallback: Default value if the environment variable is not set.
"""
if override is not None:
return override
env_value = os.getenv(env_var.value)
if env_value is None:
return fallback
return env_value
-15
View File
@@ -1,15 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from .base import ExecutionStrategy
from .client_server import ClientServerExecutionStrategy
from .events import ExecutionEvent, MultiprocessingEvent, ThreadingEvent
from .shared_memory import SharedMemoryExecutionStrategy
__all__ = [
"ExecutionStrategy",
"ClientServerExecutionStrategy",
"ExecutionEvent",
"ThreadingEvent",
"MultiprocessingEvent",
"SharedMemoryExecutionStrategy",
]
-64
View File
@@ -1,64 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
from typing import Protocol
from agentlightning.store.base import LightningStore
from .events import ExecutionEvent
logger = logging.getLogger(__name__)
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:
"""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:
"""Execute runner logic for ``worker_id`` using ``store`` and ``event``."""
class ExecutionStrategy:
"""Coordinate algorithm and runner bundles within a single process abstraction.
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.
!!! 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()
-443
View File
@@ -1,443 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import multiprocessing
import os
import signal
import time
from multiprocessing.context import BaseContext
from typing import Callable, Iterable, Literal, cast
from agentlightning.env_var import LightningEnvVar, resolve_bool_env_var, resolve_int_env_var, resolve_str_env_var
from agentlightning.store.base import LightningStore
from agentlightning.store.client_server import LightningStoreClient, LightningStoreServer
from .base import AlgorithmBundle, ExecutionStrategy, RunnerBundle
from .events import ExecutionEvent, MultiprocessingEvent
logger = logging.getLogger(__name__)
class ClientServerExecutionStrategy(ExecutionStrategy):
"""Run algorithm and runner bundles as separate processes over HTTP.
Execution Roles:
- `"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`. The runner-on-main option is limited to
`n_runners == 1` because each additional runner requires its own event
loop and process.
!!! 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 Model (four-step escalation):
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).
This mirrors the semantics implemented in
[`SharedMemoryExecutionStrategy`][agentlightning.SharedMemoryExecutionStrategy]
but adapts them to multiple processes and the HTTP client/server boundary.
"""
alias: str = "cs"
def __init__(
self,
role: Literal["algorithm", "runner", "both"] | None = None,
server_host: str | None = None,
server_port: int | None = None,
n_runners: int = 1,
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
`AGL_CURRENT_ROLE` environment variable is used.
server_host: Interface the HTTP server binds to when running the
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 `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.
terminate_timeout: How long to wait between escalation steps beyond
the cooperative phase (re-used for SIGINT, terminate, and kill).
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).
"""
resolved_role = resolve_str_env_var(LightningEnvVar.AGL_CURRENT_ROLE, override=role, fallback="both")
if resolved_role not in ("algorithm", "runner", "both"):
raise ValueError("role must be one of 'algorithm', 'runner', or 'both'")
self.role: Literal["algorithm", "runner", "both"] = resolved_role
self.n_runners = n_runners
self.server_host = resolve_str_env_var(
LightningEnvVar.AGL_SERVER_HOST, override=server_host, fallback="localhost"
)
self.server_port = resolve_int_env_var(LightningEnvVar.AGL_SERVER_PORT, override=server_port, fallback=4747)
self.graceful_timeout = graceful_timeout
self.terminate_timeout = terminate_timeout
if main_process not in ("algorithm", "runner"):
raise ValueError("main_process must be 'algorithm' or 'runner'")
if main_process == "runner":
if self.role != "both":
raise ValueError("main_process='runner' is only supported when role='both'")
if n_runners != 1:
raise ValueError("main_process='runner' requires n_runners to be 1")
self.main_process = main_process
self.managed_store = resolve_bool_env_var(
LightningEnvVar.AGL_MANAGED_STORE, override=managed_store, fallback=True
)
self.allowed_exit_codes = tuple(allowed_exit_codes)
async def _execute_algorithm(
self, algorithm: AlgorithmBundle, store: LightningStore, stop_evt: ExecutionEvent
) -> None:
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:
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 asyncio.CancelledError:
logger.info("Algorithm received CancelledError; signaling stop event")
stop_evt.set()
raise
except KeyboardInterrupt:
logger.warning("Algorithm received KeyboardInterrupt; signaling stop event")
stop_evt.set()
raise
except BaseException:
logger.exception("Algorithm bundle crashed; signaling stop event")
stop_evt.set()
raise
finally:
if self.managed_store and isinstance(wrapper_store, LightningStoreServer) and server_started:
try:
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,
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:
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 asyncio.CancelledError:
logger.debug("Runner %s received CancelledError; signaling stop event", worker_id)
stop_evt.set()
raise
except KeyboardInterrupt:
logger.warning("Runner %s received KeyboardInterrupt; signaling stop event", worker_id)
stop_evt.set()
raise
except BaseException:
logger.exception("Runner %s crashed; signaling stop event", worker_id)
stop_evt.set()
raise
finally:
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,
) -> list[multiprocessing.Process]:
"""Used when `role == "runner"` or `role == "both"` and `n_runners > 1`."""
processes: list[multiprocessing.Process] = []
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.
try:
asyncio.run(self._execute_runner(runner, worker_id, store, stop_evt))
except KeyboardInterrupt:
logger.warning("Runner (asyncio) %s received KeyboardInterrupt; exiting gracefully", worker_id)
except BaseException as exc:
logger.exception("Runner (asyncio) %s crashed by %s; signaling stop event", worker_id, exc)
raise
for i in range(self.n_runners):
process = cast(
multiprocessing.Process,
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)
processes.append(process)
return processes
def _spawn_algorithm_process(
self,
algorithm: AlgorithmBundle,
store: LightningStore,
stop_evt: ExecutionEvent,
*,
ctx: BaseContext,
) -> multiprocessing.Process:
"""Used when `main_process == "runner"`."""
def _algorithm_sync(algorithm: AlgorithmBundle, store: LightningStore, stop_evt: ExecutionEvent) -> None:
try:
asyncio.run(self._execute_algorithm(algorithm, store, stop_evt))
except KeyboardInterrupt:
logger.warning("Algorithm (asyncio.run) received KeyboardInterrupt; exiting gracefully")
except BaseException as exc:
logger.exception("Algorithm (asyncio.run) crashed by %s; signaling stop event", exc)
raise
process = cast(
multiprocessing.Process,
ctx.Process(target=_algorithm_sync, args=(algorithm, store, stop_evt), name="algorithm"), # type: ignore
)
process.start()
logger.debug("Spawned algorithm process %s (pid=%s)", process.name, process.pid)
return process
def _join_until_deadline(
self,
processes: Iterable[multiprocessing.Process],
timeout: float,
) -> list[multiprocessing.Process]:
"""Join ``processes`` until ``timeout`` elapses, returning those still alive."""
deadline = time.monotonic() + timeout
still_alive: list[multiprocessing.Process] = []
for process in processes:
remaining = deadline - time.monotonic()
if remaining > 0:
process.join(remaining)
else:
process.join(0)
if process.is_alive():
still_alive.append(process)
return still_alive
def _signal_processes(
self,
processes: Iterable[multiprocessing.Process],
action: Callable[[multiprocessing.Process], None],
) -> None:
"""Invoke ``action`` on each process while suppressing individual failures."""
for process in processes:
try:
action(process)
except Exception:
logger.exception("Error signaling process %s (pid=%s)", process.name, process.pid)
def _shutdown_processes(
self,
processes: list[multiprocessing.Process],
stop_evt: ExecutionEvent,
) -> None:
"""4-step escalation shutdown of ``processes``."""
if not processes:
logger.debug("No subprocesses to shutdown")
return
if not stop_evt.is_set():
logger.debug("Sending cooperative stop signal to subprocesses")
stop_evt.set()
else:
logger.debug("Stop event already set; waiting for subprocesses to exit")
alive = self._join_until_deadline(processes, self.graceful_timeout)
if not alive:
return
logger.warning(
"Subprocesses still alive after cooperative wait; sending SIGINT to %s",
", ".join(p.name or str(p.pid) for p in alive),
)
# SIGINT is not reliable on Windows, but we do not consider such case yet.
self._signal_processes(alive, lambda p: os.kill(cast(int, p.pid), signal.SIGINT))
alive = self._join_until_deadline(alive, self.terminate_timeout)
if not alive:
return
logger.warning(
"Subprocesses still alive after SIGINT wait; sending terminate() to %s",
", ".join(p.name or str(p.pid) for p in alive),
)
self._signal_processes(alive, lambda p: p.terminate())
alive = self._join_until_deadline(alive, self.terminate_timeout)
if not alive:
return
logger.error(
"Subprocesses still alive after terminate(); sending kill() to %s",
", ".join(p.name or str(p.pid) for p in alive),
)
self._signal_processes(alive, lambda p: p.kill())
alive = self._join_until_deadline(alive, self.terminate_timeout)
if alive:
logger.error(
"Subprocesses failed to exit even after kill(): %s", ", ".join(p.name or str(p.pid) for p in alive)
)
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 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 with unexpected exit codes: {formatted}")
def execute(self, algorithm: AlgorithmBundle, runner: RunnerBundle, store: LightningStore) -> None:
logger.info(
"Starting client-server execution with %d runner(s) [role=%s, main_process=%s]",
self.n_runners,
self.role,
self.main_process,
)
# Re-use the active multiprocessing context so the event and processes
# agree on the start method (fork/spawn/forkserver).
ctx = multiprocessing.get_context()
stop_evt = MultiprocessingEvent(ctx=ctx)
# Track spawned processes so we can enforce termination ordering and
# surface non-zero exit codes back to the caller.
processes: list[multiprocessing.Process] = []
exception: BaseException | None = None
keyboard_interrupt = False
try:
if self.role == "algorithm":
logger.info("Running algorithm solely...")
asyncio.run(self._execute_algorithm(algorithm, store, stop_evt))
elif self.role == "runner":
if self.n_runners == 1:
logger.info("Running runner solely...")
asyncio.run(self._execute_runner(runner, 0, store, stop_evt))
else:
logger.info("Spawning runner processes...")
processes = self._spawn_runners(runner, store, stop_evt, ctx=ctx)
# Wait for the processes to finish naturally.
for process in processes:
process.join()
self._check_process_exitcodes(processes)
elif self.role == "both":
if self.main_process == "algorithm":
logger.info("Spawning runner processes...")
processes = self._spawn_runners(runner, store, stop_evt, ctx=ctx)
try:
logger.info("Running algorithm...")
asyncio.run(self._execute_algorithm(algorithm, store, stop_evt))
finally:
# Always request the runner side to unwind once the
# algorithm/server portion finishes (successfully or not).
stop_evt.set()
else: # main_process == "runner"
if self.n_runners > 1:
raise ValueError("main_process='runner' requires n_runners to be 1")
logger.info("Spawning algorithm process...")
algorithm_process = self._spawn_algorithm_process(algorithm, store, stop_evt, ctx=ctx)
processes = [algorithm_process]
# Run the lone runner cooperatively in-process so users can
# attach a debugger. The algorithm + HTTP server live in
# 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, store, stop_evt))
# Wait for the algorithm process to finish.
algorithm_process.join()
else:
raise ValueError(f"Unknown role: {self.role}")
except KeyboardInterrupt:
logger.warning("KeyboardInterrupt received; initiating shutdown")
stop_evt.set()
keyboard_interrupt = True
except BaseException as exc:
logger.exception("Unhandled exception in execute method")
stop_evt.set()
# Preserve the original exception so we can avoid masking it during
# the cleanup phase.
exception = exc
raise
finally:
logger.info("Shutting down subprocesses")
self._shutdown_processes(processes, stop_evt)
if processes:
try:
self._check_process_exitcodes(processes)
except RuntimeError as err:
if exception is not None or keyboard_interrupt:
# We already propagate/handled a different failure, so
# emit a warning instead of raising a secondary error.
logger.warning("Subprocesses ended abnormally during shutdown: %s", err)
else:
raise
-69
View File
@@ -1,69 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import multiprocessing as mp
import threading
from multiprocessing.context import BaseContext
from typing import Optional, Protocol
class ExecutionEvent(Protocol):
"""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 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: ...
def clear(self) -> None: ...
def is_set(self) -> bool: ...
def wait(self, timeout: Optional[float] = None) -> bool: ...
class ThreadingEvent:
"""Thread-safe implementation of [`ExecutionEvent`][agentlightning.ExecutionEvent]."""
__slots__ = ("_evt",)
def __init__(self) -> None:
self._evt = threading.Event()
def set(self) -> None:
self._evt.set()
def clear(self) -> None:
self._evt.clear()
def is_set(self) -> bool:
return self._evt.is_set()
def wait(self, timeout: Optional[float] = None) -> bool:
return self._evt.wait(timeout)
class MultiprocessingEvent:
"""Process-safe implementation of [`ExecutionEvent`][agentlightning.ExecutionEvent]."""
__slots__ = ("_evt",)
def __init__(self, *, ctx: Optional[BaseContext] = None) -> None:
self._evt = (ctx or mp).Event()
def set(self) -> None:
self._evt.set()
def clear(self) -> None:
self._evt.clear()
def is_set(self) -> bool:
return self._evt.is_set()
def wait(self, timeout: Optional[float] = None) -> bool:
return self._evt.wait(timeout)
-16
View File
@@ -1,16 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
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"
# TODO: to be implemented
-282
View File
@@ -1,282 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import threading
from contextlib import suppress
from queue import SimpleQueue
from typing import Any, Awaitable, Callable, List, Literal, Optional, Tuple
from agentlightning.env_var import LightningEnvVar, resolve_bool_env_var
from agentlightning.store.base import LightningStore
from agentlightning.store.threading import LightningStoreThreaded
from .base import AlgorithmBundle, ExecutionStrategy, RunnerBundle
from .events import ExecutionEvent, ThreadingEvent
logger = logging.getLogger(__name__)
class SharedMemoryExecutionStrategy(ExecutionStrategy):
"""Execute bundles in a single process with cooperative worker threads.
Stop Model:
- 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.
!!! note
Signals other than `SIGINT` (such as `SIGTERM`) are not intercepted;
Python's default behavior for those signals is preserved.
"""
alias: str = "shm"
def __init__(
self,
n_runners: int = 1,
main_thread: Literal["algorithm", "runner"] = "runner",
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. "
"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_bool_env_var(
LightningEnvVar.AGL_MANAGED_STORE, override=managed_store, fallback=True
)
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. 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` inside the
bundle) remains the preferred approach.
"""
task: asyncio.Task[Any] = asyncio.create_task(coro) # type: ignore
task_exception: Optional[BaseException] = None
async def watcher() -> None:
# Poll the threading event without blocking the event loop. Using a
# background thread via ``asyncio.to_thread`` makes cancellation
# difficult because ``ThreadingEvent.wait`` is not interruptible.
# Instead we cooperatively check the flag from the loop so the
# watcher task stays cancellable and tests don't hang when the
# bundle finishes naturally before the stop event is set.
while not stop_evt.is_set():
await asyncio.sleep(self.poll_interval)
# Grace period: let a cooperative bundle exit on its own.
try:
# At this point of waiting, the main task should already see the stop event.
await asyncio.wait_for(asyncio.shield(task), timeout=self.graceful_delay) # type: ignore
logger.debug("Bundle finished by itself during grace period.")
return # bundle finished by itself during grace period
except asyncio.TimeoutError:
# Still running after the grace window.
pass
except asyncio.CancelledError:
# If someone else canceled the task already, we're done.
logger.debug("Bundle already canceled by someone else; exiting watcher.")
return
# Still running after the grace window: cancel it.
if not task.done():
logger.debug("Graceful delay elapsed; canceling bundle task...")
task.cancel()
watcher_task = asyncio.create_task(watcher())
result: Any = None
try:
# We don't wait on FIRST_COMPLETED here, because we want the watcher
# to be able to grant a grace window after stop_evt flips.
await asyncio.wait(
{task, watcher_task}, return_when=asyncio.FIRST_COMPLETED
) # pyright: ignore[reportUnknownArgumentType]
finally:
# If the main task hasn't completed yet (e.g., watcher scheduled cancel),
# finish the cancellation handshake.
if not task.done():
try:
await asyncio.wait_for(task, timeout=self.graceful_delay) # second chance
except asyncio.TimeoutError:
logger.error(
"Bundle task did not stop after cancellation; abandoning task."
"This thread could live until the process exits."
)
# We return without awaiting it. asyncio.run will still try to cancel
# pending tasks on loop close; if the task ignores cancellation, this
# thread may still stick. It's the best we can do in Python.
# We don't raise an exception here, but the thread could be a zombie.
return result
else:
# Task completed naturally; retrieve result.
try:
result = await task # type: ignore
except asyncio.CancelledError:
pass
except BaseException as exc:
task_exception = exc
watcher_task.cancel()
with suppress(asyncio.CancelledError):
await watcher_task
if task_exception is not None:
raise task_exception
return result # type: ignore
def _run_algorithm(
self,
algorithm: AlgorithmBundle,
store: LightningStore,
stop_evt: ExecutionEvent,
thread_exceptions: Optional[SimpleQueue[BaseException]],
) -> None:
try:
asyncio.run(self._run_until_completed_or_canceled(algorithm(store, stop_evt), stop_evt))
except asyncio.CancelledError:
logger.info("Algorithm bundle canceled due to stop signal.")
except BaseException as exc:
logger.exception("Algorithm bundle crashed; signaling stop to others.")
if thread_exceptions is not None:
thread_exceptions.put(exc)
stop_evt.set()
raise
def _run_runner(
self,
runner: RunnerBundle,
store: LightningStore,
worker_id: int,
stop_evt: ExecutionEvent,
thread_exceptions: Optional[SimpleQueue[BaseException]],
) -> None:
try:
asyncio.run(self._run_until_completed_or_canceled(runner(store, worker_id, stop_evt), stop_evt))
except asyncio.CancelledError:
logger.info("Runner bundle (worker_id=%s) canceled due to stop signal.", worker_id)
except BaseException as exc:
logger.exception("Runner bundle crashed (worker_id=%s); signaling stop to others.", worker_id)
if thread_exceptions is not None:
thread_exceptions.put(exc)
stop_evt.set()
raise
def execute(self, algorithm: AlgorithmBundle, runner: RunnerBundle, store: LightningStore) -> None:
logger.info(
"Starting shm execution with %d runner(s); main thread runs '%s'",
self.n_runners,
self.main_thread,
)
# Create stop event and thread-safe store.
stop_evt = ThreadingEvent()
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
def make_thread(name: str, target: Callable[..., Any], args: Tuple[Any, ...]) -> threading.Thread:
t = threading.Thread(name=name, target=target, args=args, daemon=True)
t.start()
return t
threads: List[threading.Thread] = []
try:
if self.main_thread == "algorithm":
# Start runner threads; algorithm runs on main thread.
for i in range(self.n_runners):
thread = make_thread(
name=f"runner-{i}",
target=self._run_runner,
args=(runner, thread_safe_store, i, stop_evt, thread_exceptions),
)
threads.append(thread)
# Ctrl+C here raises KeyboardInterrupt on this stack.
# Main thread doesn't need to collect exceptions.
self._run_algorithm(algorithm, thread_safe_store, stop_evt, None)
# If algo finishes naturally, request runners to stop.
stop_evt.set()
else: # main_thread == "runner"
# Start algorithm in background; runner runs on main thread.
thread = make_thread(
name="algorithm",
target=self._run_algorithm,
args=(algorithm, thread_safe_store, stop_evt, thread_exceptions),
)
threads.append(thread)
# Ctrl+C here raises KeyboardInterrupt on this stack.
# Main thread doesn't need to collect exceptions.
self._run_runner(runner, thread_safe_store, 0, stop_evt, None)
# If runner finishes naturally, WAIT FOR ALGORITHM TO FINISH.
thread.join()
if not thread_exceptions.empty():
raised_from_thread = thread_exceptions.get()
except KeyboardInterrupt:
logger.warning("KeyboardInterrupt received on main thread; initiating cooperative shutdown...")
stop_evt.set()
finally:
# Attempt a clean join; if some threads don't comply, log and move on.
for t in threads:
logger.debug("Joining thread %s...", t.name)
t.join(timeout=self.join_timeout)
alive = [t.name for t in threads if t.is_alive()]
if alive:
logger.error(
"Threads still alive after %.1fs: %s. They are daemons; continuing shutdown.",
self.join_timeout,
", ".join(alive),
)
if raised_from_thread is None and not thread_exceptions.empty():
raised_from_thread = thread_exceptions.get()
if raised_from_thread is not None:
raise raised_from_thread
+63
View File
@@ -0,0 +1,63 @@
# Copyright (c) Microsoft. All rights reserved.
"""Rollout lifecycle hooks used by enqueue and fit flows."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol
from agentlightning.schemas import RolloutCreate
if TYPE_CHECKING:
from agentlightning.schemas import Rollout
class TraceWriter(Protocol):
def add_event(self, rollout_id: str, attempt_id: str, event_type: str, data: dict[str, Any]) -> Any: ...
class RolloutHooks:
"""Base class for synchronous rollout lifecycle hooks."""
def on_startup(self, store: Any | None = None) -> None:
"""Initialize hook state once after startup."""
def on_enqueue(self, request: RolloutCreate) -> RolloutCreate:
"""Transform a rollout request before it is persisted."""
return request
def on_succeeded(self, rollout: Rollout, events: dict[str, list[Any]], store: TraceWriter) -> None:
"""Run after a rollout transitions to SUCCEEDED."""
def on_failed(self, rollout: Rollout, store: TraceWriter) -> None:
"""Run after a rollout transitions to FAILED."""
def load_hooks(path: str) -> RolloutHooks:
"""Load the single ``RolloutHooks`` subclass from a Python file."""
import importlib.util
import inspect
from pathlib import Path
module_path = Path(path).resolve()
if not module_path.exists():
raise FileNotFoundError(f"Hooks module not found: {module_path}")
spec = importlib.util.spec_from_file_location("_agl_hooks", str(module_path))
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
hook_classes = [
obj
for _, obj in inspect.getmembers(module, inspect.isclass)
if issubclass(obj, RolloutHooks) and obj is not RolloutHooks
]
if len(hook_classes) == 0:
raise ValueError(f"No RolloutHooks subclass found in {path}")
if len(hook_classes) > 1:
names = [cls.__name__ for cls in hook_classes]
raise ValueError(f"Multiple RolloutHooks subclasses found in {path}: {names}")
return hook_classes[0]()
-114
View File
@@ -1,114 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import warnings
AGENTOPS_INSTALLED: bool = False
AGENTOPS_LANGCHAIN_INSTALLED: bool = False
LITELLM_INSTALLED: bool = False
VLLM_INSTALLED: bool = False
WEAVE_INSTALLED: bool = False
try:
from . import agentops # type: ignore
AGENTOPS_INSTALLED = True # type: ignore
except ImportError:
pass
try:
from . import litellm # type: ignore
LITELLM_INSTALLED = True # type: ignore
except ImportError:
pass
# MAGIC! DO NOT TOUCH THIS!
# vllm import will cause reward tracing function to fail and produce nothing.
# try:
# from . import vllm
# VLLM_INSTALLED = True
# except ImportError:
# pass
try:
from . import agentops_langchain # type: ignore
AGENTOPS_LANGCHAIN_INSTALLED = True # type: ignore
except ImportError:
pass
def instrument_all():
"""Instrument all the instrumentation libraries."""
if AGENTOPS_INSTALLED:
from .agentops import instrument_agentops
instrument_agentops()
else:
warnings.warn("agentops is not installed. It's therefore not instrumented.")
if LITELLM_INSTALLED:
from .litellm import instrument_litellm
instrument_litellm()
else:
warnings.warn("litellm is not installed. It's therefore not instrumented.")
if VLLM_INSTALLED:
from .vllm import instrument_vllm
instrument_vllm()
else:
warnings.warn("vllm is not installed. It's therefore not instrumented.")
if AGENTOPS_LANGCHAIN_INSTALLED:
from .agentops_langchain import instrument_agentops_langchain
instrument_agentops_langchain()
else:
warnings.warn("Agentops-langchain integration is not installed. It's therefore not instrumented.")
def uninstrument_all():
"""Uninstrument all the instrumentation libraries."""
if AGENTOPS_INSTALLED:
try:
from .agentops import uninstrument_agentops
uninstrument_agentops()
except ImportError:
warnings.warn("agentops is installed but uninstrument_agentops could not be imported.")
else:
warnings.warn("agentops is not installed. It's therefore not uninstrumented.")
if LITELLM_INSTALLED:
try:
from .litellm import uninstrument_litellm
uninstrument_litellm()
except ImportError:
warnings.warn("litellm is installed but uninstrument_litellm could not be imported.")
else:
warnings.warn("litellm is not installed. It's therefore not uninstrumented.")
if VLLM_INSTALLED:
try:
from .vllm import uninstrument_vllm
uninstrument_vllm()
except ImportError:
warnings.warn("vllm is installed but uninstrument_vllm could not be imported.")
else:
warnings.warn("vllm is not installed. It's therefore not uninstrumented.")
if AGENTOPS_LANGCHAIN_INSTALLED:
try:
from .agentops_langchain import uninstrument_agentops_langchain
uninstrument_agentops_langchain()
except ImportError:
warnings.warn("agentops_langchain is installed but uninstrument_agentops_langchain could not be imported.")
else:
warnings.warn("Agentops-langchain integration is not installed. It's therefore not uninstrumented.")
-314
View File
@@ -1,314 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
import logging
from typing import Any, Callable, no_type_check
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",
]
# 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():
import agentops.instrumentation.providers.openai.stream_wrapper
import agentops.instrumentation.providers.openai.wrappers.chat
from agentops.instrumentation.providers.openai.wrappers.chat import handle_chat_attributes # type: ignore
global _original_handle_chat_attributes
if _original_handle_chat_attributes is not None:
logger.warning("AgentOps already patched. Skipping.")
return True
_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)
# 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 (
return_value is not None
and hasattr(return_value, "choices")
and return_value.choices
and isinstance(return_value.choices, list)
and len(return_value.choices) > 0
):
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
agentops.instrumentation.providers.openai.wrappers.chat.handle_chat_attributes = _handle_chat_attributes_with_tokens
agentops.instrumentation.providers.openai.stream_wrapper.handle_chat_attributes = (
_handle_chat_attributes_with_tokens
)
logger.info("Patched newer version of agentops using handle_chat_attributes")
return True
def _unpatch_new_agentops():
import agentops.instrumentation.providers.openai.stream_wrapper
import agentops.instrumentation.providers.openai.wrappers.chat
global _original_handle_chat_attributes
if _original_handle_chat_attributes is not None:
agentops.instrumentation.providers.openai.wrappers.chat.handle_chat_attributes = (
_original_handle_chat_attributes
)
agentops.instrumentation.providers.openai.stream_wrapper.handle_chat_attributes = (
_original_handle_chat_attributes
)
_original_handle_chat_attributes = None
logger.info("Unpatched newer version of agentops using handle_chat_attributes")
def _patch_old_agentops():
import opentelemetry.instrumentation.openai.shared.chat_wrappers # type: ignore
from opentelemetry.instrumentation.openai.shared.chat_wrappers import _handle_response, dont_throw # type: ignore
global _original_handle_response
_original_handle_response = _handle_response # type: ignore
@dont_throw # type: ignore
def _handle_response_with_tokens(response, span, *args, **kwargs): # type: ignore
_original_handle_response(response, span, *args, **kwargs) # type: ignore
if hasattr(response, "prompt_token_ids"): # type: ignore
span.set_attribute("prompt_token_ids", list(response.prompt_token_ids)) # type: ignore
if hasattr(response, "response_token_ids"): # type: ignore
span.set_attribute("response_token_ids", list(response.response_token_ids[0])) # type: ignore
# For LiteLLM, response is a openai._legacy_response.LegacyAPIResponse
if hasattr(response, "http_response") and hasattr(response.http_response, "json"): # type: ignore
json_data = response.http_response.json() # type: ignore
if isinstance(json_data, dict):
if "prompt_token_ids" in json_data:
span.set_attribute("prompt_token_ids", list(json_data["prompt_token_ids"])) # type: ignore
if "response_token_ids" in json_data:
span.set_attribute("response_token_ids", list(json_data["response_token_ids"][0])) # type: ignore
opentelemetry.instrumentation.openai.shared.chat_wrappers._handle_response = _handle_response_with_tokens # type: ignore
logger.info("Patched earlier version of agentops using _handle_response")
return True
def _unpatch_old_agentops():
import opentelemetry.instrumentation.openai.shared.chat_wrappers # type: ignore
global _original_handle_response
if _original_handle_response is not None:
opentelemetry.instrumentation.openai.shared.chat_wrappers._handle_response = _original_handle_response # type: ignore
_original_handle_response = None
logger.info("Unpatched earlier version of agentops using _handle_response")
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()
except ImportError as e:
logger.debug(f"Couldn't patch newer version of agentops: {str(e)}")
# Note: 0.4.15 needs another patching method, but it's too shortlived to be worth handling separately.
# Try older version (tested for 0.4.13)
try:
return _patch_old_agentops()
except ImportError as e:
logger.warning(f"Couldn't patch older version of agentops: {str(e)}")
logger.error("Failed to instrument agentops - neither patching method was successful")
return False
def uninstrument_agentops():
"""Uninstrument agentops to stop capturing token IDs."""
_unpatch_exporters()
try:
_unpatch_new_agentops()
except Exception:
pass
try:
_unpatch_old_agentops()
except Exception:
pass
class BypassableAuthenticatedOTLPExporter(LightningStoreOTLPExporter, AuthenticatedOTLPExporter):
"""
AuthenticatedOTLPExporter with switchable service control.
When `_agentops_service_enabled` is False, skip export and return success.
"""
def should_bypass(self) -> bool:
return not _agentops_service_enabled
class BypassableOTLPMetricExporter(OTLPMetricExporter):
"""
OTLPMetricExporter with switchable service control.
When `_agentops_service_enabled` is False, skip export and return success.
"""
def export(self, *args: Any, **kwargs: Any) -> MetricExportResult:
if _agentops_service_enabled:
return super().export(*args, **kwargs) # type: ignore[reportUnknownMemberType]
else:
logger.debug("SwitchableOTLPMetricExporter is switched off, skipping export.")
return MetricExportResult.SUCCESS
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
@@ -1,45 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any, Dict
from agentops import instrumentation
from agentops.integration.callbacks.langchain import LangchainCallbackHandler
original_on_chain_start = LangchainCallbackHandler.on_chain_start
langgraph_entry = None
__all__ = [
"instrument_agentops_langchain",
"uninstrument_agentops_langchain",
]
def on_chain_start(self: Any, serialized: Dict[str, Any], inputs: Dict[str, Any], **kwargs: Any) -> None:
if "name" in kwargs:
if serialized is None: # type: ignore
serialized = {}
serialized = serialized.copy()
serialized["name"] = kwargs["name"]
if "run_id" in kwargs:
if serialized is None: # type: ignore
serialized = {}
serialized = serialized.copy()
if "id" not in serialized:
serialized["id"] = kwargs["run_id"]
return original_on_chain_start(self, serialized, inputs, **kwargs)
def instrument_agentops_langchain():
"""Bypass AgentOp's native support for Langchain."""
global langgraph_entry
langgraph_entry = instrumentation.AGENTIC_LIBRARIES.pop("langgraph", None)
LangchainCallbackHandler.on_chain_start = on_chain_start
def uninstrument_agentops_langchain():
"""Restore AgentOp's native support for Langchain."""
global langgraph_entry
if langgraph_entry is not None:
instrumentation.AGENTIC_LIBRARIES["langgraph"] = langgraph_entry
langgraph_entry = None
LangchainCallbackHandler.on_chain_start = original_on_chain_start
-39
View File
@@ -1,39 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""LiteLLM instrumentations.
It's unclear whether or not this file is useful.
It seems that LiteLLM owns its own telemetry from their own entrance
[Related documentation](https://docs.litellm.ai/docs/observability/agentops_integration).
"""
from typing import Any, Optional
from litellm.integrations.opentelemetry import OpenTelemetry
__all__ = [
"instrument_litellm",
"uninstrument_litellm",
]
original_set_attributes = OpenTelemetry.set_attributes # type: ignore
def patched_set_attributes(self: Any, span: Any, kwargs: Any, response_obj: Optional[Any]):
original_set_attributes(self, span, kwargs, response_obj)
# Add custom attributes
if response_obj is not None and response_obj.get("prompt_token_ids"):
span.set_attribute("prompt_token_ids", list(response_obj.get("prompt_token_ids")))
if response_obj is not None and response_obj.get("response_token_ids"):
span.set_attribute("response_token_ids", list(response_obj.get("response_token_ids")[0]))
def instrument_litellm():
"""Instrument litellm to capture token IDs."""
OpenTelemetry.set_attributes = patched_set_attributes
def uninstrument_litellm():
"""Uninstrument litellm to stop capturing token IDs."""
OpenTelemetry.set_attributes = original_set_attributes
-81
View File
@@ -1,81 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import warnings
from typing import Any, List
import vllm.entrypoints.openai.protocol
from vllm.entrypoints.openai.protocol import ChatCompletionResponse
from vllm.entrypoints.openai.serving_chat import OpenAIServingChat
__all__ = [
"instrument_vllm",
"uninstrument_vllm",
]
class ChatCompletionResponsePatched(ChatCompletionResponse):
prompt_token_ids: List[int] | None = None
response_token_ids: List[int] | None = None
original_chat_completion_full_generator = OpenAIServingChat.chat_completion_full_generator
async def chat_completion_full_generator(
self: Any,
request: Any,
result_generator: Any,
request_id: str,
model_name: str,
conversation: Any,
tokenizer: Any,
request_metadata: Any,
) -> Any:
prompt_token_ids: List[int] | None = None
response_token_ids: List[List[int]] | None = None
async def _generate_inceptor():
nonlocal prompt_token_ids, response_token_ids
async for res in result_generator:
yield res
prompt_token_ids = res.prompt_token_ids
response_token_ids = [output.token_ids for output in res.outputs]
response = await original_chat_completion_full_generator(
self,
request,
_generate_inceptor(),
request_id,
model_name,
conversation,
tokenizer,
request_metadata,
)
response = response.model_copy(
update={
"prompt_token_ids": prompt_token_ids,
"response_token_ids": response_token_ids,
}
)
return response
def instrument_vllm():
"""Instrument vLLM to capture token IDs generated by engine.
This instrumentation has been merged to upstream vLLM since v0.10.2.
"""
if vllm.entrypoints.openai.protocol.ChatCompletionResponse is ChatCompletionResponsePatched:
warnings.warn("vllm is already instrumented. Skip the instrumentation.")
return
vllm.entrypoints.openai.protocol.ChatCompletionResponse = ChatCompletionResponsePatched
OpenAIServingChat.chat_completion_full_generator = chat_completion_full_generator
def uninstrument_vllm():
"""Uninstrument vLLM to stop capturing token IDs generated by engine."""
OpenAIServingChat.chat_completion_full_generator = original_chat_completion_full_generator
-500
View File
@@ -1,500 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
import threading
import warnings
from datetime import datetime, timezone
from typing import Any, Callable, Dict, Iterator, List
import weave.trace.weave_init
from pydantic import validate_call
from weave.trace_server import trace_server_interface as tsi
from weave.trace_server.ids import generate_id
from weave.trace_server_bindings.client_interface import TraceServerClientInterface
from weave.trace_server_bindings.models import ServerInfoRes
logger = logging.getLogger(__name__)
__all__ = [
"instrument_weave",
"uninstrument_weave",
"InMemoryWeaveTraceServer",
]
class InMemoryWeaveTraceServer(TraceServerClientInterface):
"""A minimal in-memory implementation of the TraceServerInterface.
It stores calls and objects in local dictionaries and returns valid Pydantic
responses to satisfy the Weave client and FullTraceServerInterface protocol.
"""
def __init__(self):
# Minimal storage to allow basic querying in tests
self.calls: Dict[str, tsi.CallSchema] = {}
self.partial_calls: Dict[str, Dict[str, Any]] = {}
self.objs: Dict[str, Any] = {}
self.files: Dict[str, bytes] = {}
self.feedback: List[tsi.FeedbackCreateReq] = []
self._call_threading_lock = threading.Lock()
@classmethod
def from_env(cls, *args: Any, **kwargs: Any) -> InMemoryWeaveTraceServer:
return cls()
def server_info(self) -> ServerInfoRes:
return ServerInfoRes(min_required_weave_python_version="0.52.22")
def ensure_project_exists(self, entity: str, project: str) -> tsi.EnsureProjectExistsRes:
return tsi.EnsureProjectExistsRes(project_name=project)
# --- Call API ---
@validate_call
def call_start(self, req: tsi.CallStartReq) -> tsi.CallStartRes:
# NOTE: It's not necessary that call_end must be called after call_start.
request_content = req.start.model_dump(exclude_none=True)
# If id needs to be generated here, it's very likely we won't be able to find the call later.
# This is just to make the type checker happy.
call_id = request_content.get("id") or generate_id()
trace_id = request_content.get("trace_id") or generate_id()
request_content["id"] = call_id
request_content["trace_id"] = trace_id
with self._call_threading_lock:
if call_id in self.partial_calls:
# call_end has already been called for this call.
kwargs = {**request_content, **self.partial_calls[call_id]}
self.calls[call_id] = tsi.CallSchema(**kwargs)
del self.partial_calls[call_id]
else:
self.partial_calls[call_id] = request_content
return tsi.CallStartRes(id=call_id, trace_id=trace_id)
@validate_call
def call_end(self, req: tsi.CallEndReq) -> tsi.CallEndRes:
request_content = req.end.model_dump(exclude_none=True)
call_id = req.end.id
with self._call_threading_lock:
if call_id in self.partial_calls:
# End request always override the start request content.
kwargs = {**self.partial_calls[call_id], **request_content}
self.calls[call_id] = tsi.CallSchema(**kwargs)
del self.partial_calls[call_id]
else:
self.partial_calls[call_id] = request_content
return tsi.CallEndRes()
@validate_call
def call_start_batch(self, req: tsi.CallCreateBatchReq) -> tsi.CallCreateBatchRes:
for item in req.batch:
if isinstance(item, tsi.CallStartReq):
self.call_start(item)
elif isinstance(item, tsi.CallEndReq):
self.call_end(item)
return tsi.CallCreateBatchRes(res=[])
@validate_call
def call_read(self, req: tsi.CallReadReq) -> tsi.CallReadRes:
call_data = self.calls.get(req.id)
return tsi.CallReadRes(call=call_data)
@validate_call
def calls_query(self, req: tsi.CallsQueryReq) -> tsi.CallsQueryRes:
return tsi.CallsQueryRes(calls=list(self.calls_query_stream(req)))
@validate_call
def calls_query_stream(self, req: tsi.CallsQueryReq) -> Iterator[tsi.CallSchema]:
yield from self.calls.values()
@validate_call
def calls_delete(self, req: tsi.CallsDeleteReq) -> tsi.CallsDeleteRes:
num_deleted = 0
for call_id in req.call_ids:
if call_id in self.calls:
del self.calls[call_id]
num_deleted += 1
return tsi.CallsDeleteRes(num_deleted=num_deleted)
@validate_call
def call_update(self, req: tsi.CallUpdateReq) -> tsi.CallUpdateRes:
return tsi.CallUpdateRes()
@validate_call
def calls_query_stats(self, req: tsi.CallsQueryStatsReq) -> tsi.CallsQueryStatsRes:
return tsi.CallsQueryStatsRes(count=len(self.calls))
# --- Cost API ---
@validate_call
def cost_create(self, req: tsi.CostCreateReq) -> tsi.CostCreateRes:
return tsi.CostCreateRes(ids=[(generate_id(), generate_id()) for _ in req.costs])
@validate_call
def cost_query(self, req: tsi.CostQueryReq) -> tsi.CostQueryRes:
return tsi.CostQueryRes(results=[])
@validate_call
def cost_purge(self, req: tsi.CostPurgeReq) -> tsi.CostPurgeRes:
return tsi.CostPurgeRes()
# --- Object API (Legacy V1) ---
@validate_call
def obj_create(self, req: tsi.ObjCreateReq) -> tsi.ObjCreateRes:
digest = generate_id()
self.objs[digest] = req.obj
return tsi.ObjCreateRes(digest=digest)
@validate_call
def obj_read(self, req: tsi.ObjReadReq) -> tsi.ObjReadRes:
return tsi.ObjReadRes(obj=self.objs.get(req.digest, {}))
@validate_call
def objs_query(self, req: tsi.ObjQueryReq) -> tsi.ObjQueryRes:
return tsi.ObjQueryRes(objs=[])
@validate_call
def obj_delete(self, req: tsi.ObjDeleteReq) -> tsi.ObjDeleteRes:
return tsi.ObjDeleteRes(num_deleted=0)
# --- Table API ---
@validate_call
def table_create(self, req: tsi.TableCreateReq) -> tsi.TableCreateRes:
return tsi.TableCreateRes(digest=generate_id(), row_digests=[])
@validate_call
def table_create_from_digests(self, req: tsi.TableCreateFromDigestsReq) -> tsi.TableCreateFromDigestsRes:
return tsi.TableCreateFromDigestsRes(digest=generate_id())
@validate_call
def table_update(self, req: tsi.TableUpdateReq) -> tsi.TableUpdateRes:
return tsi.TableUpdateRes(digest=generate_id(), updated_row_digests=[])
@validate_call
def table_query(self, req: tsi.TableQueryReq) -> tsi.TableQueryRes:
return tsi.TableQueryRes(rows=[])
@validate_call
def table_query_stream(self, req: tsi.TableQueryReq) -> Iterator[tsi.TableRowSchema]:
yield from []
@validate_call
def table_query_stats(self, req: tsi.TableQueryStatsReq) -> tsi.TableQueryStatsRes:
return tsi.TableQueryStatsRes(count=0)
@validate_call
def table_query_stats_batch(self, req: tsi.TableQueryStatsBatchReq) -> tsi.TableQueryStatsBatchRes:
return tsi.TableQueryStatsBatchRes(tables=[])
# --- Ref API ---
@validate_call
def refs_read_batch(self, req: tsi.RefsReadBatchReq) -> tsi.RefsReadBatchRes:
return tsi.RefsReadBatchRes(vals=[])
# --- File API ---
def file_create(self, req: tsi.FileCreateReq) -> tsi.FileCreateRes:
self.files[req.name] = req.content
return tsi.FileCreateRes(digest=generate_id())
def file_content_read(self, req: tsi.FileContentReadReq) -> tsi.FileContentReadRes:
return tsi.FileContentReadRes(content=self.files.get(req.digest, b"dummy_content"))
def files_stats(self, req: tsi.FilesStatsReq) -> tsi.FilesStatsRes:
total_size = sum(len(c) for c in self.files.values())
return tsi.FilesStatsRes(total_size_bytes=total_size)
# --- Feedback API ---
@validate_call
def feedback_create(self, req: tsi.FeedbackCreateReq) -> tsi.FeedbackCreateRes:
req.id = req.id or generate_id()
self.feedback.append(req)
return tsi.FeedbackCreateRes(
id=req.id,
created_at=datetime.now(timezone.utc),
wb_user_id="dummy_user",
payload=req.payload,
)
def feedback_create_batch(self, req: tsi.FeedbackCreateBatchReq) -> tsi.FeedbackCreateBatchRes:
results: List[tsi.FeedbackCreateRes] = []
for item in req.batch:
res = self.feedback_create(item)
results.append(res)
return tsi.FeedbackCreateBatchRes(res=results)
@validate_call
def feedback_query(self, req: tsi.FeedbackQueryReq) -> tsi.FeedbackQueryRes:
return tsi.FeedbackQueryRes(result=[])
@validate_call
def feedback_purge(self, req: tsi.FeedbackPurgeReq) -> tsi.FeedbackPurgeRes:
self.feedback.clear()
return tsi.FeedbackPurgeRes()
@validate_call
def feedback_replace(self, req: tsi.FeedbackReplaceReq) -> tsi.FeedbackReplaceRes:
return tsi.FeedbackReplaceRes(
id=req.id or generate_id(),
created_at=datetime.now(timezone.utc),
wb_user_id="dummy",
payload={},
)
# --- Action API ---
@validate_call
def actions_execute_batch(self, req: tsi.ActionsExecuteBatchReq) -> tsi.ActionsExecuteBatchRes:
return tsi.ActionsExecuteBatchRes()
# --- Execute LLM API ---
@validate_call
def completions_create(self, req: tsi.CompletionsCreateReq) -> tsi.CompletionsCreateRes:
return tsi.CompletionsCreateRes(response={"choices": [{"text": "dummy completion"}]})
@validate_call
def completions_create_stream(self, req: tsi.CompletionsCreateReq) -> Iterator[dict[str, Any]]:
yield {"choices": [{"text": "dummy "}]}
yield {"choices": [{"text": "stream"}]}
# --- Execute Image Generation API ---
@validate_call
def image_create(self, req: tsi.ImageGenerationCreateReq) -> tsi.ImageGenerationCreateRes:
return tsi.ImageGenerationCreateRes(response={})
# --- Project Statistics API ---
@validate_call
def project_stats(self, req: tsi.ProjectStatsReq) -> tsi.ProjectStatsRes:
return tsi.ProjectStatsRes(
trace_storage_size_bytes=0,
objects_storage_size_bytes=0,
tables_storage_size_bytes=0,
files_storage_size_bytes=0,
)
# --- Thread API ---
@validate_call
def threads_query_stream(self, req: tsi.ThreadsQueryReq) -> Iterator[tsi.ThreadSchema]:
yield from []
# --- Evaluation API (V1) ---
@validate_call
def evaluate_model(self, req: tsi.EvaluateModelReq) -> tsi.EvaluateModelRes:
return tsi.EvaluateModelRes(call_id=generate_id())
@validate_call
def evaluation_status(self, req: tsi.EvaluationStatusReq) -> tsi.EvaluationStatusRes:
return tsi.EvaluationStatusRes(status=tsi.EvaluationStatusNotFound())
# --- OTEL API ---
def otel_export(self, req: tsi.OtelExportReq) -> tsi.OtelExportRes:
return tsi.OtelExportRes()
# ==========================================
# Object Interface (V2 APIs)
# ==========================================
# --- Ops ---
def op_create(self, req: tsi.OpCreateReq) -> tsi.OpCreateRes:
return tsi.OpCreateRes(digest=generate_id(), object_id=generate_id(), version_index=0)
def op_read(self, req: tsi.OpReadReq) -> tsi.OpReadRes:
return tsi.OpReadRes(op=None) # type: ignore
def op_list(self, req: tsi.OpListReq) -> Iterator[tsi.OpReadRes]:
yield from []
def op_delete(self, req: tsi.OpDeleteReq) -> tsi.OpDeleteRes:
return tsi.OpDeleteRes(num_deleted=0)
# --- Datasets ---
def dataset_create(self, req: tsi.DatasetCreateReq) -> tsi.DatasetCreateRes:
return tsi.DatasetCreateRes(digest=generate_id(), object_id=generate_id(), version_index=0)
def dataset_read(self, req: tsi.DatasetReadReq) -> tsi.DatasetReadRes:
return tsi.DatasetReadRes(dataset=None) # type: ignore
def dataset_list(self, req: tsi.DatasetListReq) -> Iterator[tsi.DatasetReadRes]:
yield from []
def dataset_delete(self, req: tsi.DatasetDeleteReq) -> tsi.DatasetDeleteRes:
return tsi.DatasetDeleteRes(num_deleted=0)
# --- Scorers ---
def scorer_create(self, req: tsi.ScorerCreateReq) -> tsi.ScorerCreateRes:
return tsi.ScorerCreateRes(digest=generate_id(), object_id=generate_id(), version_index=0, scorer=generate_id())
def scorer_read(self, req: tsi.ScorerReadReq) -> tsi.ScorerReadRes:
return tsi.ScorerReadRes(scorer=None) # type: ignore
def scorer_list(self, req: tsi.ScorerListReq) -> Iterator[tsi.ScorerReadRes]:
yield from []
def scorer_delete(self, req: tsi.ScorerDeleteReq) -> tsi.ScorerDeleteRes:
return tsi.ScorerDeleteRes(num_deleted=0)
# --- Evaluations (V2) ---
def evaluation_create(self, req: tsi.EvaluationCreateReq) -> tsi.EvaluationCreateRes:
return tsi.EvaluationCreateRes(
digest=generate_id(), object_id=generate_id(), version_index=0, evaluation_ref=generate_id()
)
def evaluation_read(self, req: tsi.EvaluationReadReq) -> tsi.EvaluationReadRes:
return tsi.EvaluationReadRes(evaluation=None) # type: ignore
def evaluation_list(self, req: tsi.EvaluationListReq) -> Iterator[tsi.EvaluationReadRes]:
yield from []
def evaluation_delete(self, req: tsi.EvaluationDeleteReq) -> tsi.EvaluationDeleteRes:
return tsi.EvaluationDeleteRes(num_deleted=0)
# --- Models ---
def model_create(self, req: tsi.ModelCreateReq) -> tsi.ModelCreateRes:
return tsi.ModelCreateRes(
digest=generate_id(), object_id=generate_id(), version_index=0, model_ref=generate_id()
)
def model_read(self, req: tsi.ModelReadReq) -> tsi.ModelReadRes:
return tsi.ModelReadRes(model=None) # type: ignore
def model_list(self, req: tsi.ModelListReq) -> Iterator[tsi.ModelReadRes]:
yield from []
def model_delete(self, req: tsi.ModelDeleteReq) -> tsi.ModelDeleteRes:
return tsi.ModelDeleteRes(num_deleted=0)
# --- Evaluation Runs ---
def evaluation_run_create(self, req: tsi.EvaluationRunCreateReq) -> tsi.EvaluationRunCreateRes:
return tsi.EvaluationRunCreateRes(evaluation_run_id=generate_id())
def evaluation_run_read(self, req: tsi.EvaluationRunReadReq) -> tsi.EvaluationRunReadRes:
return tsi.EvaluationRunReadRes(evaluation_run=None) # type: ignore
def evaluation_run_list(self, req: tsi.EvaluationRunListReq) -> Iterator[tsi.EvaluationRunReadRes]:
yield from []
def evaluation_run_delete(self, req: tsi.EvaluationRunDeleteReq) -> tsi.EvaluationRunDeleteRes:
return tsi.EvaluationRunDeleteRes(num_deleted=0)
def evaluation_run_finish(self, req: tsi.EvaluationRunFinishReq) -> tsi.EvaluationRunFinishRes:
return tsi.EvaluationRunFinishRes(success=True)
# --- Predictions ---
def prediction_create(self, req: tsi.PredictionCreateReq) -> tsi.PredictionCreateRes:
return tsi.PredictionCreateRes(prediction_id=generate_id())
def prediction_read(self, req: tsi.PredictionReadReq) -> tsi.PredictionReadRes:
return tsi.PredictionReadRes(prediction=None) # type: ignore
def prediction_list(self, req: tsi.PredictionListReq) -> Iterator[tsi.PredictionReadRes]:
yield from []
def prediction_delete(self, req: tsi.PredictionDeleteReq) -> tsi.PredictionDeleteRes:
return tsi.PredictionDeleteRes(num_deleted=0)
def prediction_finish(self, req: tsi.PredictionFinishReq) -> tsi.PredictionFinishRes:
return tsi.PredictionFinishRes(success=True)
# --- Scores ---
def score_create(self, req: tsi.ScoreCreateReq) -> tsi.ScoreCreateRes:
return tsi.ScoreCreateRes(score_id=generate_id())
def score_read(self, req: tsi.ScoreReadReq) -> tsi.ScoreReadRes:
return tsi.ScoreReadRes(score=None) # type: ignore
def score_list(self, req: tsi.ScoreListReq) -> Iterator[tsi.ScoreReadRes]:
yield from []
def score_delete(self, req: tsi.ScoreDeleteReq) -> tsi.ScoreDeleteRes:
return tsi.ScoreDeleteRes(num_deleted=0)
# Module-level storage for originals
_original_init_weave_get_server: Callable[..., Any] | None = None
_original_get_entity_project_from_project_name: Callable[..., Any] | None = None
_original_get_username: Callable[..., Any] | None = None
def init_weave_get_server_factory(server: InMemoryWeaveTraceServer) -> Callable[..., Any]:
# Bypass the usage of Weave remote server
def init_weave_get_server(*args: Any, **kwargs: Any) -> InMemoryWeaveTraceServer:
return server
return init_weave_get_server
def get_entity_project_from_project_name_factory(entity_name: str) -> tuple[str, str]:
# Bypass the usage of API
try:
assert _original_get_entity_project_from_project_name is not None
if _original_get_entity_project_from_project_name is not get_entity_project_from_project_name_factory:
return _original_get_entity_project_from_project_name(entity_name)
else:
warnings.warn("W&B integration might have been repeatedly/recursively instrumented.")
return "agl", "weave"
except weave.trace.weave_init.WeaveWandbAuthenticationException:
# In case API is not available.
return "agl", "weave"
def get_username() -> str:
# Bypass the usage of API
try:
assert _original_get_username is not None
return _original_get_username()
except RuntimeError:
return "agl"
except Exception as exc:
warnings.warn(f"Unexpected error in get_username. Using default username. Error: {exc}")
return "agl"
def instrument_weave(server: InMemoryWeaveTraceServer):
"""Patch the Weave/W&B integration to bypass actual network calls for testing."""
global _original_init_weave_get_server, _original_get_entity_project_from_project_name, _original_get_username
_original_init_weave_get_server = weave.trace.weave_init.init_weave_get_server
_original_get_entity_project_from_project_name = weave.trace.weave_init.get_entity_project_from_project_name
_original_get_username = weave.trace.weave_init.get_username
weave.trace.weave_init.init_weave_get_server = init_weave_get_server_factory(server)
weave.trace.weave_init.get_entity_project_from_project_name = get_entity_project_from_project_name_factory
weave.trace.weave_init.get_username = get_username
def uninstrument_weave():
"""Restore the original Weave/W&B integration methods and HTTP requests."""
global _original_init_weave_get_server, _original_get_entity_project_from_project_name, _original_get_username
if _original_init_weave_get_server is not None:
weave.trace.weave_init.init_weave_get_server = _original_init_weave_get_server
_original_init_weave_get_server = None
else:
raise RuntimeError("Weave/W&B integration was not instrumented.")
if _original_get_entity_project_from_project_name is not None:
weave.trace.weave_init.get_entity_project_from_project_name = _original_get_entity_project_from_project_name
_original_get_entity_project_from_project_name = None
else:
raise RuntimeError("Weave/W&B integration was not instrumented.")
if _original_get_username is not None:
weave.trace.weave_init.get_username = _original_get_username
_original_get_username = None
else:
raise RuntimeError("Weave/W&B integration was not instrumented.")
-11
View File
@@ -1,11 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from .decorator import *
from .litagent import *
__all__ = [
"LitAgent",
"llm_rollout",
"prompt_rollout",
"rollout",
]
-536
View File
@@ -1,536 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Convenience decorators for building lightweight `LitAgent` implementations."""
from __future__ import annotations
import functools
import inspect
import logging
from typing import Any, Awaitable, Callable, Dict, Protocol, TypeGuard, TypeVar, Union, overload
from agentlightning.types import (
LLM,
AttemptedRollout,
NamedResources,
PromptTemplate,
ProxyLLM,
Rollout,
RolloutRawResult,
)
from .litagent import LitAgent
logger = logging.getLogger(__name__)
T = TypeVar("T")
__all__ = [
"llm_rollout",
"prompt_rollout",
"rollout",
]
T_contra = TypeVar("T_contra", contravariant=True)
class LlmRolloutFuncSync2(Protocol[T_contra]):
def __call__(self, task: T_contra, llm: LLM) -> RolloutRawResult: ...
class LlmRolloutFuncSync3(Protocol[T_contra]):
def __call__(self, task: T_contra, llm: LLM, rollout: Rollout) -> RolloutRawResult: ...
class LlmRolloutFuncAsync2(Protocol[T_contra]):
def __call__(self, task: T_contra, llm: LLM) -> Awaitable[RolloutRawResult]: ...
class LlmRolloutFuncAsync3(Protocol[T_contra]):
def __call__(self, task: T_contra, llm: LLM, rollout: Rollout) -> Awaitable[RolloutRawResult]: ...
LlmRolloutFunc = Union[
LlmRolloutFuncSync2[T_contra],
LlmRolloutFuncSync3[T_contra],
LlmRolloutFuncAsync2[T_contra],
LlmRolloutFuncAsync3[T_contra],
]
class PromptRolloutFuncSync2(Protocol[T_contra]):
def __call__(self, task: T_contra, prompt_template: PromptTemplate) -> RolloutRawResult: ...
class PromptRolloutFuncAsync2(Protocol[T_contra]):
def __call__(self, task: T_contra, prompt_template: PromptTemplate) -> Awaitable[RolloutRawResult]: ...
class PromptRolloutFuncSync3(Protocol[T_contra]):
def __call__(self, task: T_contra, prompt_template: PromptTemplate, rollout: Rollout) -> RolloutRawResult: ...
class PromptRolloutFuncAsync3(Protocol[T_contra]):
def __call__(
self, task: T_contra, prompt_template: PromptTemplate, rollout: Rollout
) -> Awaitable[RolloutRawResult]: ...
PromptRolloutFunc = Union[
PromptRolloutFuncSync2[T_contra],
PromptRolloutFuncSync3[T_contra],
PromptRolloutFuncAsync2[T_contra],
PromptRolloutFuncAsync3[T_contra],
]
class FunctionalLitAgentFunc(Protocol[T_contra]):
def __call__(
self, task: T_contra, *args: Any, **kwargs: Any
) -> Union[RolloutRawResult, Awaitable[RolloutRawResult]]: ...
class FunctionalLitAgent(LitAgent[T]):
"""Adapter that turns plain rollout functions into [`LitAgent`][agentlightning.LitAgent] instances.
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 wrapper around a rollout function.
Args:
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
self._strip_proxy = strip_proxy
self._is_async = inspect.iscoroutinefunction(rollout_func)
self._sig = inspect.signature(rollout_func)
# Copy function metadata to preserve type hints and other attributes
functools.update_wrapper(self, rollout_func) # type: ignore
def _accepts_rollout(self) -> bool:
return "rollout" in self._sig.parameters
def _accepts_llm(self) -> bool:
return "llm" in self._sig.parameters
def _accepts_prompt_template(self) -> bool:
return "prompt_template" in self._sig.parameters
def __call__(self, *args: Any, **kwargs: Any) -> Any:
"""Make the agent instance callable, preserving the original function behavior."""
return self._rollout_func(*args, **kwargs) # type: ignore
def is_async(self) -> bool:
return self._is_async
def rollout(self, task: T, resources: NamedResources, rollout: Rollout) -> RolloutRawResult:
"""Execute a synchronous rollout using the wrapped function.
Args:
task: Task input data.
resources: Mapping of named resources available to the agent.
rollout: Rollout metadata provided by the runtime.
Returns:
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.")
kwargs = self._get_kwargs(resources, rollout)
return self._rollout_func(task, **kwargs) # type: ignore
async def rollout_async(self, task: T, resources: NamedResources, rollout: Rollout) -> RolloutRawResult:
"""Execute an asynchronous rollout using the wrapped function.
Args:
task: Task input data.
resources: Mapping of named resources available to the agent.
rollout: Rollout metadata provided by the runtime.
Returns:
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.")
kwargs = self._get_kwargs(resources, rollout)
return await self._rollout_func(task, **kwargs) # type: ignore
def _get_kwargs(self, resources: NamedResources, rollout: Rollout) -> Dict[str, Any]:
"""Prepare keyword arguments expected by the wrapped rollout function.
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: Mapping of named resources available for the rollout.
rollout: Rollout metadata provided by the runtime.
Returns:
Dictionary of keyword arguments to forward to the rollout function.
"""
kwargs: Dict[str, Any] = {}
if self._accepts_rollout():
kwargs["rollout"] = rollout
if self._accepts_llm():
kwargs["llm"] = self._get_llm_resource(resources, rollout)
if self._accepts_prompt_template():
kwargs["prompt_template"] = self._get_prompt_template_resource(resources, rollout)
return kwargs
def _get_llm_resource(self, resources: NamedResources, rollout: Rollout) -> LLM:
"""Retrieve the first LLM resource from the available resources.
Strip the ProxyLLM resource into a LLM resource if needed.
Args:
resources: Mapping of named resources.
rollout: Rollout metadata used when stripping proxy endpoints.
Returns:
First [`LLM`][agentlightning.LLM] resource encountered.
Raises:
ValueError: If no LLM resource is present.
"""
resource_found: LLM | None = None
for name, resource in resources.items():
if isinstance(resource, LLM):
if resource_found is not None:
logger.warning(f"Multiple LLM resources found in resources. Using the first one: '{name}'.")
break
resource_found = resource
if resource_found is None:
raise ValueError("No LLM resource found in the provided resources.")
if self._strip_proxy:
resource_found = self._strip_proxy_helper(resource_found, rollout)
return resource_found
def _get_prompt_template_resource(self, resources: NamedResources, rollout: Rollout) -> PromptTemplate:
"""Retrieve the first prompt template resource from the available resources.
Args:
resources: Mapping of named resources.
rollout: Rollout metadata (unused).
Returns:
First [`PromptTemplate`][agentlightning.PromptTemplate] resource encountered.
Raises:
ValueError: If no prompt template resource is present.
"""
resource_found: PromptTemplate | None = None
for name, resource in resources.items():
if isinstance(resource, PromptTemplate):
if resource_found is not None:
logger.warning(
f"Multiple prompt template resources found in resources. Using the first one: '{name}'."
)
break
resource_found = resource
if resource_found is None:
raise ValueError("No prompt template resource found in the provided resources.")
return resource_found
def _strip_proxy_helper(self, proxy_llm: LLM, rollout: Rollout) -> LLM:
"""Convert [`ProxyLLM`][agentlightning.ProxyLLM] instances into concrete LLMs.
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.
Args:
proxy_llm: Candidate LLM resource.
rollout: Rollout metadata that provides rollout and attempt identifiers.
Returns:
[`LLM`][agentlightning.LLM] with rollout context baked into the endpoint.
Raises:
ValueError: If the rollout is not an
[`AttemptedRollout`][agentlightning.AttemptedRollout].
"""
if not isinstance(proxy_llm, ProxyLLM):
# Not a ProxyLLM, nothing to strip here.
return proxy_llm
# Rollout is still a Rollout here because API is not stabilized yet.
# In practice, it must be an AttemptedRollout.
if not isinstance(rollout, AttemptedRollout):
raise ValueError("Rollout is not an AttemptedRollout.")
return proxy_llm.with_attempted_rollout(rollout)
@overload
def llm_rollout(func: LlmRolloutFunc[T]) -> FunctionalLitAgent[T]: ...
@overload
def llm_rollout(*, strip_proxy: bool = True) -> Callable[[LlmRolloutFunc[T]], FunctionalLitAgent[T]]: ...
def llm_rollout(
func: LlmRolloutFunc[T] | None = None, *, strip_proxy: bool = True
) -> FunctionalLitAgent[T] | Callable[[LlmRolloutFunc[T]], FunctionalLitAgent[T]]:
"""Create a [`FunctionalLitAgent`][agentlightning.litagent.decorator.FunctionalLitAgent] for LLM-based rollouts.
Args:
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:
[`FunctionalLitAgent`][agentlightning.litagent.decorator.FunctionalLitAgent] that
wraps the supplied function.
Examples:
```python
@llm_rollout
def my_agent(task, llm):
return llm.endpoint
@llm_rollout(strip_proxy=False)
def my_agent_no_strip(task, llm):
return llm.model
result = my_agent(task, llm)
result = my_agent.rollout(task, resources, rollout)
```
"""
def decorator(f: LlmRolloutFunc[T]) -> FunctionalLitAgent[T]:
_validate_llm_rollout_func(f)
return FunctionalLitAgent(f, strip_proxy=strip_proxy)
if func is None:
# Called with arguments: @llm_rollout(strip_proxy=False)
return decorator
else:
# Called without arguments: @llm_rollout
return decorator(func)
def _validate_llm_rollout_func(func: Any) -> TypeGuard[LlmRolloutFunc[Any]]:
"""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: Function to inspect.
Returns:
`True` when the signature matches the supported patterns.
Raises:
ValueError: If the function signature does not match the expected pattern.
"""
sig = inspect.signature(func)
params = list(sig.parameters.keys())
if len(params) < 2:
raise ValueError(f"Function {func} must have at least 2 parameters.")
if params[0] != "task":
raise ValueError(f"Function {func} must be a positional parameter called 'task'.")
if "llm" not in params:
raise ValueError(f"Function {func} must have a positional parameter called 'llm'.")
return True
@overload
def prompt_rollout(func: PromptRolloutFunc[T]) -> FunctionalLitAgent[T]: ...
@overload
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`][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: 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:
[`FunctionalLitAgent`][agentlightning.litagent.decorator.FunctionalLitAgent] that
wraps the supplied function.
Examples:
```python
@prompt_rollout
def my_agent(task, prompt_template):
messages = prompt_template.format(task=task.input)
return messages
result = my_agent(task, prompt_template)
result = my_agent.rollout(task, resources, rollout)
```
"""
def decorator(f: PromptRolloutFunc[T]) -> FunctionalLitAgent[T]:
_validate_prompt_rollout_func(f)
return FunctionalLitAgent(f)
if func is None:
return decorator
else:
return decorator(func)
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: Function to inspect.
Returns:
`True` when the signature matches the supported patterns.
Raises:
ValueError: If the function signature does not match the expected pattern.
"""
sig = inspect.signature(func)
params = list(sig.parameters.keys())
if len(params) < 2:
raise ValueError(f"Function {func} must have at least 2 parameters.")
if params[0] != "task":
raise ValueError(f"Function {func} must be a positional parameter called 'task'.")
if "prompt_template" not in params:
raise ValueError(f"Function {func} must have a positional parameter called 'prompt_template'.")
return True
def rollout(func: Union[LlmRolloutFunc[T], PromptRolloutFunc[T], Callable[..., Any]]) -> FunctionalLitAgent[T]:
"""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: 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:
[`FunctionalLitAgent`][agentlightning.litagent.decorator.FunctionalLitAgent] that
wraps the supplied function.
Examples:
```python
# LLM-based agent
@rollout
def my_llm_agent(task, llm):
client = OpenAI(base_url=llm.endpoint)
response = client.chat.completions.create(
model=llm.model,
messages=[{"role": "user", "content": task.input}],
)
return response
# Prompt-template-based agent
@rollout
def my_prompt_agent(task, prompt_template):
messages = prompt_template.format(task=task.input)
# ... perform rollout with the formatted prompt
return response
# Function is still callable with original behavior
result = my_llm_agent(task, llm)
# 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.
"""
# Check if it matches the LLM rollout API pattern
sig = inspect.signature(func)
try:
if _validate_llm_rollout_func(func):
return llm_rollout(func)
except ValueError:
pass
try:
if _validate_prompt_rollout_func(func):
return prompt_rollout(func)
except ValueError:
pass
raise NotImplementedError(
f"Function signature {sig} does not match any known agent patterns. "
"Expected signatures: (task, llm[, rollout]) or (task, prompt_template[, rollout]). "
"Functions can be sync or async."
)
-252
View File
@@ -1,252 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Base abstractions for building agents that plug into Agent Lightning."""
from __future__ import annotations
import inspect
import logging
import warnings
import weakref
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 Runner
from agentlightning.tracer import Tracer
from agentlightning.trainer import Trainer
logger = logging.getLogger(__name__)
T = TypeVar("T")
__all__ = [
"LitAgent",
]
def is_v0_1_rollout_api(func: Callable[..., Any]) -> bool:
"""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: 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 implementing agent rollouts.
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 agent instance.
Args:
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(
"`trained_agents` is deprecated. Configure `agent_match` in adapter instead.",
DeprecationWarning,
stacklevel=2,
)
self.trained_agents = trained_agents
self._trainer_ref: weakref.ReferenceType[Trainer] | None = None
self._runner_ref: weakref.ReferenceType[Runner[T]] | None = None
def is_async(self) -> bool:
"""Return `True` when the agent overrides any asynchronous rollout methods.
Override this method for customized async detection logic.
"""
return (
(
hasattr(self, "training_rollout_async")
and self.__class__.training_rollout_async is not LitAgent.training_rollout_async # type: ignore
)
or (
hasattr(self, "validation_rollout_async")
and self.__class__.validation_rollout_async is not LitAgent.validation_rollout_async # type: ignore
)
or (hasattr(self, "rollout_async") and self.__class__.rollout_async is not LitAgent.rollout_async) # type: ignore
)
def set_trainer(self, trainer: Trainer) -> None:
"""Attach the trainer responsible for orchestration.
Args:
trainer: [`Trainer`][agentlightning.Trainer] that manages the agent.
"""
self._trainer_ref = weakref.ref(trainer)
def get_trainer(self) -> Trainer:
"""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()
if trainer is None:
raise ValueError("Trainer reference is no longer valid (object has been garbage collected).")
return trainer
@property
def trainer(self) -> Trainer:
"""Return the trainer associated with this agent."""
return self.get_trainer()
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) -> Tracer:
"""Return the tracer configured for this agent."""
return self.get_tracer()
def set_runner(self, runner: Runner[T]) -> None:
"""Attach the runner responsible for executing rollouts.
Args:
runner: [`Runner`][agentlightning.Runner] coordinating execution.
"""
self._runner_ref = weakref.ref(runner)
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()
if runner is None:
raise ValueError("Runner reference is no longer valid (object has been garbage collected).")
return runner
@property
def runner(self) -> Runner[T]:
"""Return the runner responsible for executing rollouts."""
return self.get_runner()
def on_rollout_start(self, task: Task, runner: Runner[T], tracer: Tracer) -> None:
"""Hook invoked immediately before a rollout begins.
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: [`Task`][agentlightning.Task] that will be processed.
runner: [`Runner`][agentlightning.Runner] managing the rollout.
tracer: [`Tracer`][agentlightning.Tracer] associated with the runner.
!!! 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: Runner[T], tracer: Tracer) -> None:
"""Hook invoked after a rollout completes.
Subclasses can override this method for cleanup or additional logging. The default
implementation is a no-op.
Args:
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.
!!! 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:
"""Execute a rollout synchronously.
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: 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:
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.
* `List[SpanCoreFields]` with Agent Lightning spans.
"""
raise NotImplementedError("Agents must implement the `rollout` method.")
async def rollout_async(self, task: T, resources: NamedResources, rollout: Rollout) -> RolloutRawResult:
"""Execute a rollout asynchronously.
Args:
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:
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:
"""Process a single training task synchronously.
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:
"""Process a single validation task synchronously.
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:
"""Process a single training task asynchronously.
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:
"""Process a single validation task asynchronously.
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)
File diff suppressed because it is too large Load Diff

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