Skip to content

Commit 86dfd38

Browse files
authored
feat(discovery): selector-driven discover() and discover_labels() (#28)
## Summary Replace the legacy hierarchical discovery trio (`describe_fleet`, `list_devices`, `get_device_functions`) with a single selector-driven pair (`discover`, `discover_labels`) plus a label foundation. One grammar covers device-only, device.function, device.event, function-only, and event-only queries; same string drives every discovery and operation tool. Three additive layers: 1. **Labels foundation.** Optional `labels: dict[str, str | list[str]]` field on `DeviceCapabilities`, `FunctionDef`, and `EventDef`, populated via class-level `DeviceDriver.labels = {...}` or `@rpc(labels=...)` / `@emit(labels=...)` decorator kwargs. 2. **Selector DSL.** Pure-Python parser at `device_connect_edge.selector` mapping a structured string onto a `Selector` dataclass. Supports `key:value`, `key:[v1,v2]` (OR within key), `key:pattern*` (anchored glob), `k1:v1,k2:v2` (AND across keys), and bare-string id/name match. 3. **`discover()` and `discover_labels()` tools.** Selector-driven discovery with stable pagination envelope (`{scope, matched, returned, offset, next_offset, results, label_histogram}`) and a `label_histogram` so callers can choose how to narrow next without a second call. Errors returned as data with structured `{code, message}` for the five failure modes. `flatten_device` mirrors the legacy `DeviceStatus.location` into `labels["location"]` when capabilities don't declare one, so existing drivers populating only the heartbeat field remain discoverable. The legacy trio remains for one release as advisory-deprecated wrappers (each emits a `DeprecationWarning` pointing at the equivalent `discover()` invocation). All first-party adapters (Claude Agent SDK, Strands, LangChain, the in-tree `StrandsOpenAIDeviceConnectAgent`) migrated to `discover` / `discover_labels` so they don't trigger the warning. ## What's new vs `main` - New tools: `discover(selector, offset, limit)`, `discover_labels(key, offset, limit)` - New module: `device_connect_edge.selector` (parser + matcher, dependency-free stdlib only) - Labels field on `FunctionDef` / `EventDef` / `DeviceCapabilities` - `@rpc(labels=...)` / `@emit(labels=...)` decorator kwargs - `flatten_device` legacy-location mirror - Adapters migrated; integration tests added; ADR added at `docs/adr/0001-selector-driven-discovery.md` ## Backwards compatibility - `describe_fleet` / `list_devices` / `get_device_functions` still work — they emit a `DeprecationWarning` pointing to the equivalent `discover()` call. Existing tests in `test_tools_hierarchical.py` continue to pass against them. - `discover_devices()` (the long-deprecated flat-roster tool) also gains a `DeprecationWarning` for parity. - Drivers do not need to declare labels to be discoverable; `discover("device(*)")` returns everything, and the legacy-location mirror keeps location-based queries working for drivers that only set `DeviceStatus.location`. ## Test plan - [x] 912 unit tests pass (495 edge + 159 agent-tools + 258 server) - [x] 91 integration tests pass on NATS backend (`tests/tests/test_tools_selector.py` adds 22 new tests covering all five scope shapes, label filters, OR-within-key, AND-across-keys, pagination, error envelope, and `discover_labels` per-axis + per-key forms) - [x] No existing integration test broken - [ ] CI integration tests on Zenoh backend (skipped locally) ## Commits ``` feat(types): add labels to capabilities, functions, and events feat(selector): add selector DSL parser and matcher feat(discovery): selector-driven discover and discover_labels feat(discovery): structure discover/discover_labels error envelope ```
1 parent 81fb15d commit 86dfd38

27 files changed

Lines changed: 3407 additions & 185 deletions

File tree

docs/discovery.md

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
# Discovery
2+
3+
Device Connect uses one selector grammar to address devices, functions, and
4+
events. The same selector string drives discovery: it tells the system
5+
**which** entities you mean. Labels attached to devices, functions, and
6+
events provide the dimensions to filter on.
7+
8+
This guide covers the labels schema, the selector grammar, and the two
9+
tools that resolve selectors.
10+
11+
## Labels
12+
13+
Labels are key/value metadata. Values are strings or lists of strings.
14+
Lists express composite identity (a smart camera that is both `camera` and
15+
`inference`).
16+
17+
Drivers declare labels in two places:
18+
19+
```python
20+
class SmartCamera(DeviceDriver):
21+
labels = {
22+
"category": ["camera", "inference"],
23+
"location": "lab-A/optics-bench",
24+
}
25+
26+
@rpc(labels={"direction": "write", "modality": ["rgb", "4k"]})
27+
async def capture_image(self, resolution: str = "1080p") -> dict:
28+
...
29+
30+
@emit(labels={"modality": "motion"})
31+
async def state_change_detected(self, zone_id: str, state_class: str):
32+
...
33+
```
34+
35+
### Well-known keys
36+
37+
These keys carry conventional meaning. Custom keys are always allowed
38+
alongside them.
39+
40+
| Question | Key | Applies to | Example values |
41+
| --- | --- | --- | --- |
42+
| What is it? | `category` | device | `camera`, `robot`, `hub`, `sensor`, `actuator`, `inference` |
43+
| Where is it? | `location` | device | `lab-A`, `zone-A/dock` (`/`-hierarchical, glob-able) |
44+
| Read or write? | `direction` | function (RPC) | `read`, `write` |
45+
| Is it dangerous? | `safety` | function + event | `critical`, `informational` |
46+
| What kind of signal? | `modality` | function + event | `rgb`, `thermal`, `infrared`, `motion`, `4k`, ... |
47+
48+
The RPC-vs-event distinction is structural (FunctionDef vs EventDef) and is
49+
expressed by the selector scope, not by a label.
50+
51+
### Drivers without label declarations
52+
53+
Drivers that populate only the legacy `DeviceStatus.location` heartbeat
54+
field are still discoverable by location: the value is mirrored into
55+
`labels["location"]` at the discovery boundary so selector queries on
56+
location work without a driver change.
57+
58+
## Selector grammar
59+
60+
```
61+
device(<filters>) device-only
62+
device(<filters>).function(<filters>) RPCs on a device subset
63+
device(<filters>).event(<filters>) events on a device subset
64+
function(<filters>) all RPCs across the fleet
65+
event(<filters>) all events across the fleet
66+
```
67+
68+
Inside `(...)`:
69+
70+
- `key:value` - single-value match
71+
- `key:[v1,v2]` - OR within a key (matches if the label value contains any
72+
listed value; multi-valued labels match if any element is in the list)
73+
- `key:pattern*` - anchored glob (`*`, `?`); `set_*` matches `set_threshold`
74+
but not `unset_threshold`. Use `*set*` for substring.
75+
- `k1:v1,k2:v2` - AND across keys
76+
- bare string (no colon) - id/name match: `device(robot-001)`,
77+
`function(capture_image)`. Globs allowed: `device(cam-*)`.
78+
- `*` or empty - match all
79+
80+
Keys inside `device(...)` resolve against device labels; keys inside
81+
`function(...)` resolve against function labels; keys inside `event(...)`
82+
resolve against event labels. The `.` chains: "narrow to these devices,
83+
then narrow to these functions or events on them."
84+
85+
### Case sensitivity
86+
87+
Selector matching is **case-sensitive** on both label keys and values, so
88+
`device(category:Camera)` and `device(category:camera)` are not
89+
equivalent. Kubernetes label selectors and AWS resource tag matching are
90+
case-sensitive; we follow that convention. Use lowercase for label keys
91+
and values as the convention in this repo; drivers in `tests/drivers/`
92+
follow that.
93+
94+
### Selector examples
95+
96+
```
97+
device(category:camera) all cameras
98+
device(category:[camera,robot], location:lab-A/*) cameras or robots in lab-A
99+
device(location:lab-A*) lab-A and any descendant
100+
device(*).function(direction:write, modality:rgb) rgb-producing writes fleet-wide
101+
device(*).event(modality:motion) all motion events
102+
function(safety:critical) critical RPCs fleet-wide
103+
function(estop) fleet emergency-stop targets
104+
```
105+
106+
## Tools
107+
108+
### `discover(selector, offset=0, limit=200)`
109+
110+
Resolves a selector to matched entities. Returns devices, function tuples,
111+
or event tuples depending on the selector scope. The response includes a
112+
`label_histogram` so you can see which dimensions to narrow on next without
113+
a separate call.
114+
115+
`discover()` includes full schemas inline when the matched set is small,
116+
and switches to a name-and-labels summary above
117+
`DEVICE_CONNECT_FUNCTION_THRESHOLD` (default 20). The threshold is
118+
configurable via environment variable.
119+
120+
### `discover_labels(key=None, offset=0, limit=50)`
121+
122+
Returns the fleet label vocabulary. Use this first when you do not know
123+
which dimensions are available.
124+
125+
- With no `key`: returns top values per key across each axis (`device_keys`,
126+
`function_keys`, `event_keys`).
127+
- With a `key` like `"device.location"` or `"function.direction"`:
128+
paginates the full value list for that one key.
129+
130+
## Response envelopes
131+
132+
The three response shapes below — one for `discover`, two for
133+
`discover_labels` — are the source of truth for callers. Fields not
134+
listed are reserved for forward-compatible extensions; do not rely on
135+
field order.
136+
137+
### `discover`
138+
139+
```json
140+
{
141+
"scope": "device_only",
142+
"matched": 47,
143+
"returned": 20,
144+
"offset": 0,
145+
"next_offset": 20,
146+
"results": [...],
147+
"label_histogram": {
148+
"category": {
149+
"values": {"camera": 312, "robot": 89, "sensor": 601},
150+
"multivalued": true,
151+
"unique_devices": 1002
152+
}
153+
}
154+
}
155+
```
156+
157+
Fields:
158+
159+
- `scope` - one of `device_only`, `device_function`, `device_event`,
160+
`function_only`, `event_only`.
161+
- `matched` - total matched entities (across all pages).
162+
- `returned` - rows in this page.
163+
- `offset` / `next_offset` - pagination cursor; `next_offset` is `null` when
164+
no more pages.
165+
- `results` - per-page rows. Shape depends on scope (devices, function
166+
tuples, or event tuples).
167+
- `label_histogram` - per-key vocabulary across the matched set
168+
(pre-pagination), so you can choose how to narrow next. On the device
169+
axis, multi-valued keys also carry `unique_devices`.
170+
171+
The hard ceiling on `limit` is 1000 to prevent runaway responses; ask for
172+
more pages instead.
173+
174+
### `discover_labels` — multi-axis form (no `key`)
175+
176+
```json
177+
{
178+
"total_devices": 1247,
179+
"total_functions": 7100,
180+
"total_events": 1292,
181+
"device_keys": {
182+
"category": {
183+
"values": {"camera": 312, "robot": 89, "sensor": 601},
184+
"multivalued": true,
185+
"unique_devices": 1002
186+
},
187+
"location": {
188+
"values": {"warehouse1/loading-dock": 120, "warehouse1/yard": 80, "lab-A/optics-bench": 45},
189+
"more": 1227
190+
}
191+
},
192+
"function_keys": {
193+
"direction": {"values": {"read": 4200, "write": 2900}}
194+
},
195+
"event_keys": {
196+
"modality": {"values": {"motion": 812, "thermal": 480}}
197+
}
198+
}
199+
```
200+
201+
Fields:
202+
203+
- `total_devices` / `total_functions` / `total_events` - fleet-wide entity
204+
counts on each axis.
205+
- `device_keys` / `function_keys` / `event_keys` - per-axis vocabulary.
206+
Each value is a map of label key → entry, where each entry contains:
207+
- `values` - `{value: count}` map sorted by descending count, capped
208+
at the top-N most-frequent values per key (default `20`,
209+
configurable via `DEVICE_CONNECT_LABEL_VALUES_TOP_N`).
210+
- `more` - present and `> 0` iff the value list was cropped; the
211+
integer count of values omitted from this page. Omitted when no
212+
truncation occurred. To enumerate the full list, switch to the
213+
per-key form (`discover_labels(key="device.location")`).
214+
- `multivalued` - present and `true` iff at least one entity carries a
215+
list value for this key. Omitted when the key is single-valued
216+
everywhere on this axis.
217+
- `unique_devices` - device-axis only; the number of devices that
218+
carry this key at least once (deduplicates list values). Omitted on
219+
the function and event axes.
220+
221+
The same per-key entry shape is used inside `discover()`'s
222+
`label_histogram`, including `more` truncation. Per-key
223+
`discover_labels(key=...)` enumerates fully via its own pagination
224+
cursor and is not truncated.
225+
226+
### `discover_labels` — per-key form (`key="device.location"`, etc.)
227+
228+
This form is paginated, not truncated: every distinct value is reachable
229+
across pages via the `offset` / `next_offset` cursor. There is no
230+
`more` field here; that field is specific to the multi-axis form above.
231+
232+
```json
233+
{
234+
"axis": "device",
235+
"key": "location",
236+
"matched": 247,
237+
"returned": 50,
238+
"offset": 0,
239+
"next_offset": 50,
240+
"values": {"lab-A/optics-bench": 12, "lab-A/dock": 9, "warehouse1/yard": 8},
241+
"axis_total": 1247,
242+
"multivalued": true
243+
}
244+
```
245+
246+
Fields:
247+
248+
- `axis` - one of `"device"`, `"function"`, `"event"` (parsed from the
249+
dotted `key` argument).
250+
- `key` - the label key without the axis prefix.
251+
- `matched` - total distinct values for this key on this axis (across
252+
all pages).
253+
- `returned` - values on this page.
254+
- `offset` / `next_offset` - pagination cursor; `next_offset` is `null`
255+
when no more pages.
256+
- `values` - `{value: count}` map for this page, sorted by descending
257+
count.
258+
- `axis_total` - total entities on this axis (e.g., devices when
259+
`axis == "device"`); use as the denominator if you want coverage
260+
percentages.
261+
- `multivalued` - present and `true` iff this key is multivalued on this
262+
axis. Omitted otherwise.
263+
264+
## Error responses
265+
266+
`discover` and `discover_labels` return errors as data inside the response
267+
envelope rather than raising. The shape is stable so callers can branch on
268+
the `code` programmatically and surface `message` to logs or users:
269+
270+
```json
271+
{ "matched": 0, "returned": 0, "offset": 0, "next_offset": null,
272+
"results": [],
273+
"error": {
274+
"code": "selector_parse_error",
275+
"message": "Unknown scope 'widgets' at position 0\n widgets(*)\n ^"
276+
}
277+
}
278+
```
279+
280+
| Code | Cause |
281+
| --- | --- |
282+
| `invalid_selector` | Selector is not a string (or otherwise unusable as input) |
283+
| `selector_parse_error` | Selector is a string but malformed |
284+
| `connection_error` | Registry or messaging backend unavailable |
285+
| `key_not_axis_qualified` | `discover_labels(key=...)` missing the `device.` / `function.` / `event.` prefix |
286+
| `unknown_axis` | `discover_labels(key=...)` axis prefix not in `{device, function, event}` |
287+
288+
## Worked examples
289+
290+
### Browse the fleet vocabulary
291+
292+
```python
293+
from device_connect_agent_tools import connect, discover_labels
294+
295+
connect()
296+
vocab = discover_labels()
297+
# {"total_devices": 1247, "total_functions": 7100, "total_events": 1292,
298+
# "device_keys": {"category": {...}, "location": {...}},
299+
# "function_keys": {"direction": {...}, "modality": {...}, "safety": {...}},
300+
# "event_keys": {"modality": {...}}}
301+
302+
# Drill into one dimension:
303+
locations = discover_labels(key="device.location", limit=50)
304+
```
305+
306+
### Find every camera in lab-A
307+
308+
```python
309+
from device_connect_agent_tools import discover
310+
311+
result = discover("device(category:camera, location:lab-A/*)")
312+
for d in result["results"]:
313+
print(d["device_id"], d["labels"])
314+
```
315+
316+
### Find every write RPC on cameras, fleet-wide
317+
318+
```python
319+
result = discover("device(category:camera).function(direction:write)")
320+
for row in result["results"]:
321+
print(row["device_id"], row["name"])
322+
```
323+
324+
### Paginate a large result set
325+
326+
```python
327+
offset = 0
328+
while True:
329+
page = discover("device(*)", offset=offset, limit=200)
330+
for d in page["results"]:
331+
process(d)
332+
if page["next_offset"] is None:
333+
break
334+
offset = page["next_offset"]
335+
```
336+
337+
## Known limits
338+
339+
### Client-side filtering (v1)
340+
341+
`discover()` and `discover_labels()` currently load the full fleet via
342+
`Connection.list_devices()` and apply the selector in-process. This is
343+
fine at today's fleet sizes (low hundreds of devices) but does not scale
344+
to the 10K-device worked example: the entire device list crosses the
345+
wire on every call, regardless of how selective the selector is.
346+
347+
Push-down to the registry is intentionally deferred for v1 — the
348+
selector grammar and response envelopes are designed so that swapping
349+
the in-process filter for a registry-side query is a transparent
350+
optimization, not a breaking change. Until then, callers running
351+
against large fleets should:
352+
353+
- prefer `discover_labels(key=…)` over `discover()` when they only need
354+
vocabulary, and
355+
- treat `discover("device(*)")` as an O(fleet) operation, not O(matched).
356+
357+
The operations layer should plan for push-down ahead of fleet growth
358+
past ~1K devices.

0 commit comments

Comments
 (0)