Skip to content

feat(examples): publish examples manifest to agents-jukebox#5751

Open
theomonnom wants to merge 42 commits into
mainfrom
examples-manifest-publishing
Open

feat(examples): publish examples manifest to agents-jukebox#5751
theomonnom wants to merge 42 commits into
mainfrom
examples-manifest-publishing

Conversation

@theomonnom
Copy link
Copy Markdown
Member

@theomonnom theomonnom commented May 16, 2026

No description provided.

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.
@chenghao-mou chenghao-mou requested a review from a team May 16, 2026 19:36
devin-ai-integration[bot]

This comment was marked as resolved.

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.
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +136 to +143
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
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot May 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Open in Devin Review

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.
devin-ai-integration[bot]

This comment was marked as resolved.

theomonnom added 14 commits May 16, 2026 22:36
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.
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 18 additional findings in Devin Review.

Open in Devin Review

Comment thread examples/drive-thru/agent.py Outdated
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)
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread examples/drive-thru/agent.py Outdated
Comment on lines +498 to +501
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))
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

theomonnom and others added 7 commits May 17, 2026 18:50
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.
@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


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.
Théo Monnom added 4 commits May 17, 2026 20:22
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".
devin-ai-integration[bot]

This comment was marked as resolved.

Théo Monnom added 4 commits May 17, 2026 20:51
- 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'`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants