Lab instrument monitoring — building marker-associated visual agents#
The lab instrument sample is a reference for applications that combine foreground questions, opt-in background work, persistent participant state, visual identification, and event-driven notifications. Refer to the quickstart to run the sample. This architecture reference focuses on reusing the sample’s structure for another application.
The central design choice is to separate perception, interpretation, state, policy, and presentation. A marker scan identifies an instrument, a VLM reads its display, a stateful agent decides whether the reading changed, and a separate subscriber decides whether to speak. Each concern can therefore be replaced without rewriting the others.
What to copy#
Use this sample when your application needs one or more of these patterns:
a foreground agent that selects participant-bound tools;
visual queries without exposing participant IDs or raw images to the model;
background tasks that start and stop through ordinary agent tools;
state that survives intermittent visibility but is cleared on disconnect;
typed events consumed independently by storage, speech, or a backend;
deterministic identity before VLM interpretation;
JSONL artifacts and captured source frames for manual debugging.
The instrument-reading behavior is sample-specific. The runtime, voice, tool-loop, image-selection, visual-query, and event patterns are reusable.
Architecture#
camera frames ────────────────> ParticipantImageAgent
│
accepted speech or typed query ├─> current frame + generic VLM query
│ └─> marker scan
v │
ForegroundAgent v
│ LabInstrumentAgent
├─> direct answer │ one-frame readings
├─> visual monitor controls v
├─> instrument controls InstrumentMonitorAgent
└─> current readings │
├─> change event ─┬─> voice aggregation
├─> lost event ───┤
│ └─> JSONL/backend
└─> state event ────> JSONL/backend
VoiceAgent publishes participant-scoped queries and lifecycle events into
AgentRuntime. Agents call peer tools directly, while typed topics fan results
out to independent consumers. LiveKit remains inside DeviceIOHub and does not
appear in the worker’s agent contracts.
The worker uses native xr_ai_runtime agents and xr_ai_tools instances. It
does not use NVIDIA Agent Toolkit, PydanticAI, MCP clients, or MCP servers.
Agent responsibilities#
Agent |
Owns |
Does not own |
|---|---|---|
|
Image registry, current-frame selection, marker tracking, participant cleanup |
VLM prompts or instrument state |
|
One bounded tool loop per current query, supersession, participant-bound tool catalog |
Conversation history or background scheduling |
|
An optional generic visual-change task per participant |
Instrument identity or alerts |
|
One-frame marker-associated reads and debug source images |
Long-lived state or notification policy |
|
Reading normalization, change detection, last-seen state, loss detection, periodic snapshots |
Speech or storage |
|
Which instrument events become participant voice output |
Tracking state |
|
Participant speech pacing, ordering, and coalescing |
Instrument or foreground decisions |
|
Session files, JSONL records, recent visual-history tool |
Agent decisions |
|
Explicit sample-event projection into browser topics |
HTTP serving or durable history |
|
Bounded in-memory history and read-only HTTP page |
Application topic selection or persistence |
This ownership keeps periodic tasks and mutable state out of the runtime. The runtime delivers typed events; each agent cancels its own participant tasks and releases its own state.
For a current_view selection, ForegroundAgent uses the same direct path as
simple-vlm-example: CurrentFrameTool selects one frame and
StreamingImageQueryTool publishes chunks to participant voice. The tool is a
direct return, so the completed visual answer does not incur a second language
model call. A dedicated current-view system prompt applies visible-evidence,
plain-speech, and response-length rules directly to the streaming VLM output.
The default voice gate accepts agent and hey agent, plays a listening chime,
and allows a five-second follow-up. VoiceAgent mirrors spoken text to the
client’s agent.response topic so the same output can be rendered as captions.
Source map#
File |
Purpose |
Typical adaptation |
|---|---|---|
|
Constructs services and registers agents |
Add or replace an event consumer |
|
Typed application topics and payloads |
Define backend-facing facts, alerts, or audit records |
|
Shared participant image acquisition |
Add another image-selection tool |
|
Current-query tool loop and participant tool composition |
Change the assistant’s available operations |
|
Marker scan, marker annotation, and one-frame VLM read |
Replace the identified-object perception flow |
|
Resolves marker family and ID to a domain name |
Load identities from another source |
|
Stateful reading tracker and event policy |
Change normalization, loss, or alert thresholds |
|
Converts selected events to voice |
Send UI notifications or suppress speech |
|
Generic opt-in visual change monitor |
Build a different periodic visual task |
|
JSONL persistence and recent-history access |
Publish records to a customer backend |
|
Explicit runtime-topic projections for the browser viewer |
Select or rename live presentation topics |
|
Sample identity data |
Map real marker IDs to domain objects |
|
Five QR and five ArUco examples |
Print or replace with deployment markers |
Refer to the generated configuration and command-line references for configuration fields and command syntax.
Configuration#
Run and edit the sample from agent-samples/lab-instrument-monitoring/. The
orchestrator reads the files below on every start and materializes a temporary
worker configuration with absolute paths. Edit the checked-in files, not the
temporary copy named in the logs.
File |
Owns |
|---|---|
|
Monitor and snapshot cadence, lost-device threshold, image freshness, VAD, output directory, and event-viewer port and history |
|
QR payloads and ArUco IDs mapped to instrument names |
|
Wake phrases, listening chime, and follow-up window |
|
Model adapters and shared endpoints |
|
LiveKit room and ports, web and token servers, and network behavior |
For example, shorten both visual polling periods by setting
monitor_interval_s and instrument_monitor_interval_s in the worker YAML,
or replace entries under devices in device_map.yaml with the identifiers
attached to real instruments. Paths such as device_map_yaml and
artifacts_dir are resolved relative to the worker YAML.
Restart lab_instrument_monitoring after an edit; configuration is not
hot-reloaded. The --expose-web-events option intentionally overrides
web_events_host in the runtime copy, so use that option rather than editing
the host to expose the unauthenticated viewer. Changing models.json changes
only the endpoints consumed by this sample. Refer to
Customizing model servers for server-side model, GPU, port, or
memory changes, then restart the persistent shared stack.
Refer to the generated configuration reference for exact fields, checked-in values, and adjacent YAML comments.
Foreground tool loop#
Each accepted query gets only the system prompt and current utterance. The
foreground agent builds a ToolSet whose closures already contain the
participant ID, then calls the shared bounded run_tool_loop. The model never
selects a participant and never receives raw image bytes.
The tool catalog contains:
current-view visual Q&A;
recent visual-monitor history;
start, stop, and status controls for generic monitoring;
a one-shot marker-associated instrument read;
start, stop, and status controls for continuous instrument tracking.
Control operations and one-shot reads use direct-return results. They do not require another model round to restate a result that the tool already knows. The loop executes tools sequentially so state-changing calls cannot race.
To add a foreground capability:
Implement a native
ToolorAsyncTooland keep its request schema limited to model-selectable arguments.Capture participant or application context in the tool closure.
Call another agent’s public tool through
execute()rather than its private handler.Add the participant-bound facade to
ForegroundAgent._participant_tools.Add a routing eval that expects the new tool without reusing a prompt example verbatim.
Visual identification and reading#
LabInstrumentAgent implements a deterministic perception pipeline:
Select one current participant frame.
Optionally save that exact source image under
artifacts/marker-scans/whencapture_marker_scansis enabled.Detect every QR and ArUco marker in the frame.
Resolve each marker through
DeviceMap.Create one derived image that draws a distinct, bold colored X over each configured marker polygon. The X stays aligned to the image frame even when the detected marker is rotated.
Ask the VLM once for a strict JSON map from every requested color name to that X marker’s own display reading or
UNKNOWN.Return mapped
InstrumentSightingvalues independently from successfulInstrumentReadingvalues.
The marker determines identity before the VLM reads the display. This prevents
the model from guessing which instrument produced a value. Image references,
not image bytes, pass between tools; media stays in the shared image registry.
Color names remain internal correlation keys. User-facing results contain only
device names resolved from scanned markers through DeviceMap; they never use
the temporary color name as a device identity. Parsed response values are
validated independently: an invalid value is treated as UNKNOWN without
discarding valid readings returned for other colors in the same response.
To use a different identifier, replace the marker tool and DeviceMap while
preserving the one-frame LabInstrumentReadResult boundary. Barcode, OCR label,
object detection, or backend inventory lookup can all feed the same reader and
tracker shape.
Stateful monitoring#
InstrumentMonitorAgent owns one task group and tracker per participant. Its
scan loop calls the one-frame reader. Its maintenance loop publishes lost-device
events and complete state snapshots.
Readings are normalized before comparison. If a later VLM response omits a unit, the previous known unit is retained. The agent publishes:
InstrumentChangewhen a device is first discovered;InstrumentChangewhen its normalized value or unit changes;InstrumentLostonce after the device exceeds the last-seen timeout;InstrumentStateSnapshotperiodically, including tracking status.
A mapped marker refreshes last-seen state even when its display is temporarily unreadable, so glare does not produce a false lost-device alert. A device moving in and out of view does not repeatedly alert unless its reading changes. This is application policy and belongs in the tracker rather than the VLM prompt or voice agent.
Only marker identities present in device_map.yaml are treated as instruments.
Unknown QR payloads and ArUco IDs are logged and ignored, preventing detector
false positives from becoming names such as ArUco 17. Unmapped detections do
not consume a color or appear in the VLM request. The reader supports six
colored X markers in one request. If more than six configured markers are
visible, it logs the overflow, reads the first six in top-to-bottom and
left-to-right order, and still returns sightings for every configured marker.
The one-frame reader treats each colored X as a trusted marker association and
matches it to the nearest compatible display without crossing a visible device
boundary. One display may not be assigned to multiple X markers. The reader
returns UNKNOWN when the display is unreadable or two nearby displays are
equally plausible. These fixed rules are supplied as a system prompt; decoded
marker identities remain outside the prompt and visible text is treated as
evidence, never as instructions.
Connecting a backend#
Add a runtime agent that subscribes to the typed topics you need. Inject a typed backend client into that agent; do not add vendor HTTP calls to the perception or tracking agents.
class InstrumentBackendAgent(Agent):
def __init__(self, backend: InstrumentBackend) -> None:
self._backend = backend
super().__init__()
@subscribe(INSTRUMENT_CHANGE_TOPIC)
async def changed(
self,
event: InstrumentChange,
ctx: RuntimeContext,
) -> None:
await self._backend.publish_change(
participant_id=ctx.metadata.participant_id,
event=event,
)
Register this subscriber beside FileOutputAgent. Storage and voice can remain
enabled during development and be removed independently for production. Use a
typed msgpack over ZMQ service when the backend boundary is a separate process.
Live event viewer#
WebEventsAdapterAgent subscribes explicitly to monitor, instrument change,
tracking-loss, state, foreground, transcript, and participant lifecycle topics.
It publishes compact WebEvent projections to the shared WebEventsAgent; it
does not tail JSONL files or subscribe by wildcard. The page at
http://127.0.0.1:8092 groups those events by participant and presentation
topic while the JSONL files remain the durable record. Pass
--expose-web-events to bind all IPv4 interfaces for access at
http://<xr-host>:8092, and restrict TCP port 8092 to a trusted network.
The viewer is read-only and bounded by web_events_max_events. Its listener has
no participant authentication or TLS. Use the externally reachable sample mode
only on a trusted development network, or put an authenticated TLS reverse
proxy in front of it.
File outputs and persistence#
Each non-empty final STT result delivered on voice.transcript is written to
transcript.jsonl before voice gating, including ambient speech rejected by a
configured wake phrase. Transcript-topic delivery is bounded and best effort;
overflow drops the oldest pending transcript, and shutdown discards pending
items. Wake-word gating controls dispatch to the foreground, not storage:
delivered rejected speech is still persisted. The default gate accepts agent
and hey agent, plays a listening chime, and allows one follow-up utterance for
five seconds. Accepted speech reaches the foreground as a UserQuery. Typed
text reaches the foreground but is not an STT transcript.
Each connection writes to a new participant-scoped directory:
artifacts/
├── relay-events.jsonl
└── <participant>-<utc-session-stamp>/
├── monitor.jsonl
├── instrument-monitoring.jsonl
├── transcript.jsonl
└── foreground.jsonl
Every per-session file begins with a session record and receives a
session_end record on participant departure once that output type has been
written. Monitor records contain a baseline, observations, unavailable-frame
notices, or errors. Foreground records include the query, response, and
model-selected tool names. Relay events contain prompts, responses, participant
metadata, and tool lifecycles; live camera bytes are redacted by the shared
vision tool. When capture_marker_scans is enabled, every lab-instrument
invocation saves the exact source JPEG under marker-scans/; the worker log
records that path and marker-family counts for debugging. Unmapped marker
payloads are represented by a bounded hash rather than logged verbatim.
Instrument monitoring records contain discrete changes, one-time
tracking-loss events, and complete state snapshots.
Adapting the sample#
Track another measured object#
Replace
device_map.yamlwith the real identity map.Replace the marker assets or identifier implementation.
Update the focused reading prompt in
LabInstrumentAgent.Extend
normalize_meter_readingonly for domain-specific formats.Keep identity, reading, and normalized value as separate fields.
Add an alert rule#
Keep measurement state in InstrumentMonitorAgent. Publish a new typed event
when the rule changes state, then subscribe from a separate presentation agent.
This avoids mixing notification cooldowns, speech, or backend failures into the
tracking loop.
Replace files with production persistence#
Subscribe to the same record and instrument topics from a new agent. Maintain
participant correlation from RuntimeContext.metadata; do not add participant
IDs to model-visible schemas. Decide whether to block, retry, buffer, or drop
the event after a delivery failure—the runtime does not impose that application
policy.
Build a different background visual flow#
Use MonitorAgent as the minimal pattern: participant-bound start, stop, and
status tools, one owned task per participant, current-frame selection, a
dedicated ImageQueryTool, typed output events, and cancellation on participant
leave.
Lifecycle invariants#
A participant join creates fresh output and application state.
Participant-leave subscribers run concurrently. Each handler must tolerate another subscriber cancelling work or releasing image references first.
A superseding foreground query cancels the previous participant turn.
Agent tasks fork the Relay context so tool execution remains traceable.
Model and tool cancellation propagates; it is not converted into a result.
Only subscribed participants participate in worker readiness.
Preserve these rules when moving logic between agents. Most intermittent leaks and cross-participant bugs come from changing ownership without moving cleanup.
Validation strategy#
Use three layers:
Unit-test deterministic state normalization, change detection, last-seen policy, and device-map resolution.
Decode every checked-in marker and verify its resolved device name.
Evaluate foreground routing separately from the monitor and instrument VLM prompt contracts in
eval/visual_eval.py.
For manual pipeline testing, enable capture_marker_scans and inspect the saved
marker-scan image first. It is
the exact source frame used for the marker scan and separates camera and framing
problems from detector or VLM problems. Then inspect Relay events and the
participant JSONL files to follow the tool call, reading, state update, and
notification as distinct stages.
Routing and visual evals#
Start the shared model servers, then run these commands from
agent-samples/lab-instrument-monitoring/:
uv run --project worker python eval/eval.py
uv run --project worker python eval/visual_eval.py
eval/cases.yaml checks the foreground model’s complete first action, exact
tool-call count, and every call’s request model. It separates current-view,
recent-history, background-control, ordinary conversation, and general
knowledge requests. eval/visual_cases.yaml exercises generated images for
monitor baselines and changes, adversarial visible instructions, multiple
readable devices, competing markers, and exact joint label-to-reading output.
Printable sample markers#
The checked-in marker assets match yaml/device_map.yaml:
Files |
Family |
Encoded IDs |
Device names |
|---|---|---|---|
|
QR |
|
|
|
ArUco |
|
|
Keep the white border, print without interpolation or cropping, and place a marker close enough to its instrument display for both to be clear in one camera frame.