feat(examples): publish examples manifest to agents-jukebox#5751
feat(examples): publish examples manifest to agents-jukebox#5751theomonnom wants to merge 42 commits into
Conversation
Add `playground.yaml` metadata to healthcare, survey, frontdesk, and drive-thru examples (title, description, pixel-art icon, accent color, entry file, tags). A new `scripts/build_examples_manifest.py` walks the examples tree and emits a single JSON manifest. The deploy-examples workflow grows a `publish-manifest` job that runs after the matrix deploys, builds the manifest, and commits it to `livekit/agents-jukebox` (next/data/examples-manifest.json) via a deploy key. The push is a no-op when the manifest is byte-identical, so we avoid noisy redeploys.
Drop the per-example playground.yaml files and the standalone build_examples_manifest.py script. Replace with one top-level examples/playground.yaml plus an inline merge step in the workflow that folds in each example's README and livekit.toml at publish time. Accent colors switch from palette names (cyan/green/…) to hex (#10B981 etc.) so the frontend isn't constrained to the playground's 8-slot palette. Pixel-art icons use 1/0 instead of #/. for equal-width visualization in monospace editors.
None of the four deployed examples currently set agent_name in their rtc_session decorator, which means they all register with an empty name and dispatch is non-deterministic when they share a project. Inject LIVEKIT_AGENT_NAME=<slug> into .env.deploy so the worker's fallback branch picks it up (worker.py:503), giving each example a stable name matching its directory slug. The playground.yaml carries the same `agent_name` field so the manifest is self-describing for the frontend dispatch.
The frontend dispatches by agent_name (set via LIVEKIT_AGENT_NAME at deploy) and connects via LIVEKIT_EXAMPLES_URL (env var on the jukebox host). It no longer needs agent_id or subdomain in the manifest, so the inline workflow script can skip parsing livekit.toml entirely. The toml files themselves stay — lk agent deploy reads them to find and update the existing agent on redeploy.
| for i in 1 2; do | ||
| if git push origin HEAD; then | ||
| echo "Pushed manifest update." | ||
| exit 0 | ||
| fi | ||
| echo "Push failed (attempt $i), pulling and retrying..." | ||
| git pull --rebase origin HEAD | ||
| done |
There was a problem hiding this comment.
🟡 Push retry loop wastes last rebase and logs misleading "retrying" message
The for i in 1 2 loop performs a git pull --rebase after every failed push, including the final iteration. On the last iteration (i=2), if the push fails, the script rebases and prints "Push failed (attempt 2), pulling and retrying..." but the loop then exits and no subsequent push attempt is made. This means: (1) the rebase after the second failed push is wasted work, (2) the log message promises a retry that never happens, and (3) you get only 2 push attempts when the structure suggests 2 retries after the initial attempt (i.e., 3 total). The fix is either to change the loop to for i in 1 2 3 (giving 3 push attempts with 2 rebases between them) or to move the rebase/message into a conditional that skips on the last iteration.
Was this helpful? React with 👍 or 👎 to provide feedback.
Move the per-example livekit.toml deployment markers into examples/playground.yaml as `agent_id` fields, alongside a single top-level `project.subdomain` shared by all examples. CI regenerates a fresh livekit.toml for each matrix example from this yaml before invoking `lk agent deploy` — output is byte-identical to what was checked in. The yaml now carries every piece of state needed to describe and dispatch to a deployed example (subdomain, agent_id, agent_name, display metadata), so adding or retiring an example is a single-file edit instead of a multi-file dance.
Use slug as the key under `examples:` instead of a list of items with a `slug:` field — slug appears once, can't be duplicated by construction, and the file reads top-down as "here's healthcare, here's its config" rather than "here's an entry, oh and its slug is healthcare". The CI merge step and per-example livekit.toml regeneration follow the new shape.
The map key under `examples:` is already the agent name — it's used as the directory name, the LIVEKIT_AGENT_NAME injected at deploy time, and the dispatch target on the jukebox side. Carrying a separate agent_name field inside each value just duplicated the key.
Vercel honors [skip ci] the same way GitHub Actions does — including it on the manifest publish commit would skip the redeploy, leaving the jukebox serving a stale manifest. The whole point of the publish step is to trigger a fresh deploy.
The jukebox frontend now fetches the manifest at runtime from /examples-manifest.json (served as a static file by Vercel) so it can update independently of the wasm bundle. Move the published location to next/public/ so Vercel serves it directly.
The README already holds the long-form prose, so the manifest's `description` field is now a 3-5 word tagline meant for card display. Anything longer than a line lives in README.md.
Walks each example directory and inlines every text source file
(.py, .toml, .md, .yaml, .json, Dockerfile, requirements.txt, etc.)
into an `files: [{ path, language, content }]` array on the manifest.
The wasm playground reads this to render the in-app file explorer
with syntax highlighting alongside the README.
Single-file cap at 200KB so a runaway log or generated file can't
balloon the JSON. Binaries (.pdf, .mp3, .dockerignore, .DS_Store)
are skipped.
The README is already in files[] (as README.md, language=markdown). Drop the duplicate top-level readme field — frontend now picks README.md out of files[] as the default selection.
Bare GitHub URLs in any .md file that point at examples/<slug>/<file> with a #L start (and optional -L end) anchor are now replaced with a fenced code block holding the referenced lines, followed by an attribution link back to the original permalink. Resolves only against the same example's collected files (cheap O(1) lookup), so cross- example references stay as plain links. This is what GitHub itself does when you paste such a URL on its own line — we replicate that for our in-app viewer where the URL would otherwise just render as plain text.
The fenced code block now stands on its own — the user can see the code, no need for a 'From file Lx-Ly (url)' link underneath. Removes the visual noise that read as a stray link to nothing useful.
Snippets pulled from inside a function or class were keeping their original indentation (commonly 4-8 spaces of leading whitespace). That meant every code block in the README rendered far from the left margin and overflowed horizontally for no good reason. Run textwrap.dedent on the snippet so the smallest leading indent becomes column 0 — the code still parses, just sits flush.
Two changes:
- Add a top-level repo: block to playground.yaml with tree_url and
file_url templates ({slug}/{path}). The playground reads this from
the manifest instead of hardcoding the github.com/livekit/agents
URL in its C++.
- Loosen the GH_LINK matcher in inline_links from fullmatch to search,
splitting the line into prose-before / code / prose-after so inline
permalink references like 'set up here: <url>' get inlined too. The
survey README had one of these slipping through.
Moves the tree URL out of a top-level repo: block into a per-example
github: field. Different examples could plausibly live in different
repos (third-party contributions, monorepo splits), so it's cleaner
to keep each one self-describing rather than relying on {slug}
substitution against one shared template.
The CI inliner now emits fences like ```python file=survey_agent.py start=285 end=315 so the playground's in-app markdown renderer can draw a line-number gutter aligned with the original source and attach a 'View on GitHub' link to each snippet.
Aligns the example metadata with the existing playground design: - Accents now reference raw-colors.ts brand entries (green-500, amber-500, indigo-500, red-500) instead of arbitrary one-off hexes. - Icons drop to 8x8 — at the card scale we render them, fewer / larger cells read cleaner than a sparse 16x16. - Tags trimmed from 4 each down to 1-2 distinctive ones. Previously the sidebar listed more tags than there were examples.
- healthcare: heart (universal health symbol) - survey: clipboard with form-line pattern - frontdesk: bell with handle, dome, and base tray - drive-thru: burger with sesame top + clear filling layers
Playground no longer bundles a code browser — it links out to the example's github_url for source and README. The manifest can drop the per-example files[] payload (and the markdown #L inliner that built it). Manifest size goes from ~200KB to under 2KB.
Minimal voice agent that exposes its STT, LLM, and TTS as RPC methods (`stt`, `llm`, `tts`). The playground reads the matching `controls` block from playground.yaml and renders one labeled pill dropdown per control; picking an option fires the RPC and the agent reconfigures without restarting the session. STT/TTS use `update_options(model=...)`; the inference LLM mutates `_opts.model` directly since it doesn't expose update_options yet. Lists every current LiveKit Inference STT/LLM/TTS model ID as a selectable option.
…get) `controls` and `views` in playground.yaml now use a full `rpc` method name (e.g. `set_stt_model`, `set_cart_content`) instead of a short `id`, so it's clear from the YAML alone that the value is an RPC method. Drive-thru ships the first user of `views`: an `OrderState.on_change` hook formats the current order as markdown (using `**bold**` for item names) and pushes it to the playground's `set_cart_content` view via `perform_rpc`. The push is fire-and-forget — the function tool that mutated the order doesn't block on round-tripping the RPC, and slow or dropped peers can't stall each other. Inference example renames its RPC handlers to match.
| parts.append(_name_for(userdata.sauce_items, item.sauce_id)) | ||
| lines.append(f"- **{meal}** — {', '.join(parts)}") | ||
| elif isinstance(item, OrderedRegular): | ||
| name = _name_for(userdata.regular_items, item.item_id, item.size) |
There was a problem hiding this comment.
🔴 format_cart only searches regular_items for OrderedRegular, missing drinks and sauces ordered individually
The order_regular_item tool (built at examples/drive-thru/agent.py:273) accepts items from regular_items + drink_items + sauce_items, creating OrderedRegular entries for all three categories. However, format_cart at line 427 only looks up the item in userdata.regular_items. When a user orders a standalone drink (e.g., "A medium Coke" → item_id="coca_cola") or sauce (e.g., "Can I get some ketchup?" → item_id="ketchup"), _find(userdata.regular_items, item.item_id, item.size) returns None, causing the cart to display the raw item_id string instead of the human-readable name and a price of $0.00 instead of the actual price. This makes the cart total incorrect.
Was this helpful? React with 👍 or 👎 to provide feedback.
| async def push_cart() -> None: | ||
| payload = format_cart(userdata) | ||
| for p in list(ctx.room.remote_participants.values()): | ||
| asyncio.create_task(_push_to(p.identity, payload)) |
There was a problem hiding this comment.
🟡 Fire-and-forget asyncio.create_task without saving references risks silent task disappearance
push_cart calls asyncio.create_task(_push_to(...)) inside a loop without saving the returned task object. Python's event loop only holds a weak reference to tasks, so the task can be garbage-collected mid-execution if no strong reference exists. The Python docs explicitly warn: "Save a reference to the result of this function, to avoid a task disappearing mid-execution." The codebase itself uses the set.add / add_done_callback(set.discard) pattern elsewhere (e.g. livekit-agents/livekit/agents/ipc/job_proc_executor.py:119-120). Here the RPC push tasks may silently vanish, causing cart updates to be dropped without any error log.
Was this helpful? React with 👍 or 👎 to provide feedback.
Drive-thru `format_cart` now totals each ordered item (combo / happy / regular) and emits markdown with the FA shopping-cart heading icon and prices wrapped in [[…]] so the playground renders them in the example accent. Switched the item-detail separator from em-dash to middle dot. Frontdesk declares a `set_appointment_status` view and pushes to it from both list_available_slots (a markdown list of free times) and schedule_appointment (a "Booked" confirmation). The push helper is fire-and-forget so the function-tool reply path isn't blocked on the RPC round-trip.
Pulls all playground-specific code (markdown formatting, FontAwesome glyphs, RPC routing) out of frontdesk_agent.py and into a dedicated ui_view.py module. The agent now only knows that Userdata may carry an optional UIView, and calls two semantic methods on it (slots_listed / appointment_booked). When the agent runs anywhere other than the playground, ui is None and the agent behaves exactly as it would have before this view existed. Also: fix the misleading Dockerfile comment for the inference example — `python -m livekit.agents download-files` pre-fetches whatever each plugin chooses to expose via its download_files hook (e.g. the turn detector ONNX), not Silero VAD weights specifically.
Now that the playground renders the YAML `title` (with FA escapes honored by the body font merge), the agent-side markdown payload doesn't need to re-emit a `# Available slots` / `# Booked` heading. Drop the redundant heading from the frontdesk push so the card chrome stays consistent across examples and the title font stays larger than the body.
Removing several items in one tool call (`remove_order_item` accepts a list) iterates `await order.remove(oid)` per item, and each remove fired an independent `asyncio.create_task(_push_to(...))`. That left N RPCs in flight concurrently; if even one of the intermediate states arrived after the final empty-cart payload, the view would be left showing stale items. Replace it with a single-runner coalescer: `push_cart` just flips a pending bit and (if no runner is active) spawns one. The runner captures the *current* cart state, pushes to all peers via `asyncio.gather`, and loops as long as more changes came in while it was awaiting the RPC. Net effect: at most one push in flight, the trailing push always reflects the latest state, and the function-tool reply path is still never blocked on the RPC. Also adds a small `push_cart: N chars` info log so empty-payload pushes are visible when debugging "cart didn't clear" reports.
|
Théo Monnom seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
A three-month query was rendering 30+ slot rows into the playground card, which overflowed the viewport. The agent's tool reply still carries every slot (so the LLM can still pick), but the card now collapses to a one-line summary: the requested window, the slot count, and the date span actually returned.
Drops the `session.llm._opts.model = ...` private-attribute write in favour of the new public `inference.LLM.update_options(model=...)`. When the user picks a new STT / LLM / TTS in the playground, the agent now says a short "Switched to <model> for <modality>" line so the swap is audible — particularly useful for TTS, where the announcement is voiced by the new model and doubles as a sanity check that the swap actually took. Depends on the SDK PR adding `inference.LLM.update_options`.
`session.say` with a hardcoded "Switched to deepgram/nova-3" line read out every hyphen and slash. Replace it with `session.generate_reply(instructions=...)` so the LLM produces the acknowledgement itself and is explicitly asked to pronounce the model id as a brand (e.g. "Deepgram Nova 3", not "deepgram slash nova dash three"). Drops the announce helper — the call site is short enough to inline.
`ctx.room.local_participant` raises until the room is connected, and the room is connected as part of `session.start(room=ctx.room)` — so the previous decorator order (register_rpc_method before start) blew up on the first job with "cannot access local participant before connecting".
- Replace the placeholder agent_id with the real CA_K9e3yQ3RPNKQ from `lk agent create`. - Add inference to the deploy-examples matrix so the workflow generates livekit.toml, builds, and ships it alongside the other examples. - Pick up the Dockerfile / .dockerignore created by `lk agent create` (the download-files step isn't needed for this minimal pipeline). - Drop the unused `order:` / `deploy:` fields from every entry — neither was read anywhere (frontend uses YAML insertion order; deploy CI uses a hardcoded matrix).
Every example carried its own near-copy of the same Dockerfile, drifting in small ways (inference had no `download-files`, the others had verbose tutorial-style comments). Hard to keep in sync. Replace all five with a single canonical template. The only per-example variation — the entry script name — is now an env var (`LIVEKIT_AGENT_ENTRY`) with a sensible default (`agent.py`). The deploy CI reads `entry:` out of `playground.yaml` and injects the right value into `.env.deploy` for examples whose entry is `<slug>_agent.py`. Side effects: - `download-files` runs in inference's image too (its requirements include livekit-plugins-silero, so the cold-download stall applied equally). - The CMD is now `sh -c 'exec python "$LIVEKIT_AGENT_ENTRY" start'`.
No description provided.