debug-control-plane 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- debug_control_plane/__init__.py +7 -0
- debug_control_plane/device_discovery/__init__.py +82 -0
- debug_control_plane/device_discovery/device_candidates.py +326 -0
- debug_control_plane/device_discovery/device_pool.py +369 -0
- debug_control_plane/device_discovery/discovery/__init__.py +14 -0
- debug_control_plane/device_discovery/discovery/cross_identify.py +396 -0
- debug_control_plane/device_discovery/discovery/lan_scan.py +183 -0
- debug_control_plane/device_discovery/discovery/manual_registry.py +245 -0
- debug_control_plane/device_discovery/discovery/usb_identity.py +228 -0
- debug_control_plane/device_discovery/discovery/vpn_immune.py +103 -0
- debug_control_plane/device_discovery/endpoint.py +317 -0
- debug_control_plane/device_discovery/protocol.py +230 -0
- debug_control_plane/mcp_plane/__init__.py +47 -0
- debug_control_plane/mcp_plane/bridge_client.py +525 -0
- debug_control_plane/mcp_plane/capability_mirror.py +642 -0
- debug_control_plane/mcp_plane/semantic_provider.py +57 -0
- debug_control_plane/mcp_plane/server.py +874 -0
- debug_control_plane-0.1.0.dist-info/METADATA +98 -0
- debug_control_plane-0.1.0.dist-info/RECORD +23 -0
- debug_control_plane-0.1.0.dist-info/WHEEL +5 -0
- debug_control_plane-0.1.0.dist-info/entry_points.txt +2 -0
- debug_control_plane-0.1.0.dist-info/licenses/LICENSE +21 -0
- debug_control_plane-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""Dynamic capability mirror — translate ``/hello`` into a tool manifest.
|
|
2
|
+
|
|
3
|
+
(R020-BF009)
|
|
4
|
+
|
|
5
|
+
Role (design §3.4 CapabilityMirror / §4.1 CapabilitySchema / §4.2 tools /
|
|
6
|
+
§5.2 dynamic-mirror decision flow / §5.5 tool-table consistency):
|
|
7
|
+
|
|
8
|
+
The CapabilityMirror is the **adapter layer** between the phone's runtime
|
|
9
|
+
capability schema (``/hello.registeredCapabilities``, FF002 output) and
|
|
10
|
+
the MCP tool surface. It produces a *tool manifest* (name / description /
|
|
11
|
+
JSON-schema input) that BF011 (McpServer) maps onto the MCP SDK.
|
|
12
|
+
|
|
13
|
+
Three orthogonal concerns (design §3.4 single-direction dependency):
|
|
14
|
+
|
|
15
|
+
* **Identity** (``device_id``) is stable — handled by :class:`DevicePool`.
|
|
16
|
+
* **HTTP probe** (``/hello``) is the only outbound arrow — owned by
|
|
17
|
+
:class:`BridgeClient.hello`.
|
|
18
|
+
* **Mirror** (this module) is pure logic: ``NetworkTarget`` →
|
|
19
|
+
``list[ToolSpec]``. No SDK dependency, no I/O of its own.
|
|
20
|
+
|
|
21
|
+
Why a custom ToolSpec (not the mcp SDK ``Tool``)? BF009 is the **logic layer**
|
|
22
|
+
(the /hello → tool-list translation), BF011 is the **SDK integration layer**
|
|
23
|
+
(map ToolSpec → mcp Tool + register handler). Keeping them separate means
|
|
24
|
+
BF009 is unit-testable without booting a server and BF011 owns the single
|
|
25
|
+
SDK touchpoint (YAGNI + layered design).
|
|
26
|
+
|
|
27
|
+
Dynamic-mirror decision flow (design §5.2 — "app-not-running must not lie"):
|
|
28
|
+
|
|
29
|
+
* pool empty / device_id unknown → static meta + device-mgmt only
|
|
30
|
+
* /hello unreachable (offline/stale/HTTP error)
|
|
31
|
+
→ clear cache, static only (degrade)
|
|
32
|
+
* new app (registeredCapabilities) → parse CapabilitySchema[]
|
|
33
|
+
* known cap (a SemanticProvider matches)
|
|
34
|
+
→ semantic-sugar tools (BF010 gamepad)
|
|
35
|
+
* unknown cap (no provider matches) → D3 fallback: reachable ONLY via
|
|
36
|
+
the meta ``invoke_command`` tool
|
|
37
|
+
(no extra tool added)
|
|
38
|
+
* legacy app (no FF002, static ``capabilities`` only)
|
|
39
|
+
→ E 方案 sentinel:对每 capability
|
|
40
|
+
tag 生成 sentinel schema 交
|
|
41
|
+
provider.matches 认领(AC8 degrade)
|
|
42
|
+
|
|
43
|
+
Why SemanticProvider is pluggable (decoupling BF010):
|
|
44
|
+
|
|
45
|
+
The gamepad semantic sugar is BF010's job. BF009 defines a *hook*
|
|
46
|
+
(``SemanticProvider`` protocol + a provider registry on CapabilityMirror)
|
|
47
|
+
so BF010 can register its gamepad provider without BF009 importing it.
|
|
48
|
+
BF009 ships with **no built-in provider**; its own tests inject a stub
|
|
49
|
+
provider to exercise the hook mechanism. This keeps BF009 free of any
|
|
50
|
+
forward dependency on BF010.
|
|
51
|
+
|
|
52
|
+
list_changed via pure polling (design §5.5 ③, this iteration only):
|
|
53
|
+
|
|
54
|
+
``refresh(device_id)`` re-fetches /hello, parses the schema, and compares
|
|
55
|
+
it against the cached snapshot via :meth:`_diff_changed`. If the manifest
|
|
56
|
+
changed, the cache is rebuilt and ``True`` is returned so BF011 can emit
|
|
57
|
+
``notifications/tools/list_changed``. **This iteration does NOT implement
|
|
58
|
+
the ``/events capability_changed`` real-time side channel** (R019
|
|
59
|
+
register/unregister don't emit it; design §5.5 D-3 lists it as plan-TBD).
|
|
60
|
+
BF009 only provides the change-detection signal; BF011 owns the actual
|
|
61
|
+
notification send.
|
|
62
|
+
|
|
63
|
+
Refs:
|
|
64
|
+
- tasks: .dev-flow/R020/mcp-bridge-device-discovery-tasks.md BF009
|
|
65
|
+
- design: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-backend.md
|
|
66
|
+
§3.4 CapabilityMirror / §4.1 CapabilitySchema / §4.2 / §5.2 / §5.5
|
|
67
|
+
- test: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-test.md §2.1
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
from __future__ import annotations
|
|
71
|
+
|
|
72
|
+
from dataclasses import dataclass
|
|
73
|
+
from typing import Any, Protocol
|
|
74
|
+
|
|
75
|
+
from debug_control_plane.device_discovery.protocol import JsonMap, NetworkTarget
|
|
76
|
+
|
|
77
|
+
# BF007: 包内相对 bridge_client + 跨包 device_discovery.protocol(BF006 已迁)。
|
|
78
|
+
from .bridge_client import (
|
|
79
|
+
BridgeClient,
|
|
80
|
+
BridgeError,
|
|
81
|
+
DeviceHttpError,
|
|
82
|
+
DeviceStale,
|
|
83
|
+
DeviceUnreachable,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# DTOs — capability schema mirror (design §4.1 CapabilitySchema)
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class ResourceDecl:
|
|
93
|
+
"""A single REST resource declared by a phone capability.
|
|
94
|
+
|
|
95
|
+
Mirrors ``registeredCapabilities[].resources[]`` (FF002). ``description``
|
|
96
|
+
is optional — legacy resources registered without a description yield
|
|
97
|
+
``None`` (FF002 D2 backward-compatible constructor).
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
method: str
|
|
101
|
+
path: tuple[str, ...]
|
|
102
|
+
description: str | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class CommandDecl:
|
|
107
|
+
"""A single command declared by a phone capability.
|
|
108
|
+
|
|
109
|
+
Mirrors ``registeredCapabilities[].commands[]`` (FF002). ``description``
|
|
110
|
+
is optional for the same reason as :class:`ResourceDecl`.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
method: str
|
|
114
|
+
path: tuple[str, ...]
|
|
115
|
+
description: str | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class CapabilitySchema:
|
|
120
|
+
"""A single phone capability mirrored from ``/hello`` (design §4.1).
|
|
121
|
+
|
|
122
|
+
Frozen + tuple-valued so two schemas parsed from identical JSON compare
|
|
123
|
+
equal — :meth:`CapabilityMirror._diff_changed` relies on this for the
|
|
124
|
+
list_changed change-detection signal (design §5.5 ③).
|
|
125
|
+
|
|
126
|
+
Attributes:
|
|
127
|
+
capability_id: the capability ``id`` (e.g. ``gamepad``).
|
|
128
|
+
resources: declared REST resources (read side).
|
|
129
|
+
commands: declared commands (write side).
|
|
130
|
+
description: optional capability-level description (may be absent).
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
capability_id: str
|
|
134
|
+
resources: tuple[ResourceDecl, ...] = ()
|
|
135
|
+
commands: tuple[CommandDecl, ...] = ()
|
|
136
|
+
description: str | None = None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# ToolSpec — manifest DTO (decoupled from mcp SDK, design D3)
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class ToolSpec:
|
|
146
|
+
"""A single tool's manifest entry (name + description + input schema).
|
|
147
|
+
|
|
148
|
+
BF009 produces the *manifest*; BF011 maps each ToolSpec onto the MCP SDK
|
|
149
|
+
(``Tool`` + handler registration). ``input_schema`` is a JSON Schema dict
|
|
150
|
+
describing the tool's arguments (the MCP spec requires one per tool).
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
name: str
|
|
154
|
+
description: str
|
|
155
|
+
input_schema: dict[str, Any]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# SemanticProvider — pluggable hook for known-capability sugar (BF010)
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class SemanticProvider(Protocol):
|
|
164
|
+
"""Builds semantic-sugar tools for a known capability.
|
|
165
|
+
|
|
166
|
+
BF010 (gamepad) implements this protocol and registers its instance on
|
|
167
|
+
:class:`CapabilityMirror` via the ``providers`` constructor arg. BF009
|
|
168
|
+
ships no built-in provider — the hook itself is exercised by a stub in
|
|
169
|
+
BF009's own tests.
|
|
170
|
+
|
|
171
|
+
Contract:
|
|
172
|
+
* :meth:`matches` is called once per parsed schema; return ``True`` if
|
|
173
|
+
this provider owns the capability (it then gets to build tools for
|
|
174
|
+
it). ``legacy_capabilities`` is the static ``/hello.capabilities``
|
|
175
|
+
frozenset (legacy/degrade mode); providers may match on it when no
|
|
176
|
+
structured schema is present (E 方案 sentinel 路径)。
|
|
177
|
+
* :meth:`build_tools` returns the manifest entries for the matched
|
|
178
|
+
capability. The schema is the structured mirror (when present) or a
|
|
179
|
+
synthetic legacy schema (capability_id set, empty resources/commands)
|
|
180
|
+
so providers don't have to special-case legacy mode.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def matches(
|
|
184
|
+
self,
|
|
185
|
+
schema: CapabilitySchema,
|
|
186
|
+
*,
|
|
187
|
+
legacy_capabilities: frozenset[str] | None = None,
|
|
188
|
+
) -> bool:
|
|
189
|
+
"""Return True if this provider owns the given capability."""
|
|
190
|
+
...
|
|
191
|
+
|
|
192
|
+
def build_tools(self, schema: CapabilitySchema) -> list[ToolSpec]:
|
|
193
|
+
"""Return the semantic-sugar tools for the matched capability."""
|
|
194
|
+
...
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Static tool manifests — meta + device-management (design §4.2.1 / §4.2.2)
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
#: Common JSON-Schema fragment for the ``device_id`` parameter. Used across
|
|
202
|
+
#: all per-device tools so the spelling stays consistent.
|
|
203
|
+
_DEVICE_ID_PARAM: dict[str, Any] = {
|
|
204
|
+
"type": "object",
|
|
205
|
+
"properties": {
|
|
206
|
+
"device_id": {
|
|
207
|
+
"type": "string",
|
|
208
|
+
"description": "Stable device identity (from list_devices).",
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
"required": ["device_id"],
|
|
212
|
+
"additionalProperties": False,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#: Generic meta tools reachable for ANY capability (design §4.2.2). Always
|
|
216
|
+
#: present in the manifest regardless of /hello state — this is the floor
|
|
217
|
+
#: the AI can rely on even when no phone app is running (AC8 degrade + AC10
|
|
218
|
+
#: unknown-cap fallback both route through ``invoke_command``).
|
|
219
|
+
_STATIC_META_TOOLS: tuple[ToolSpec, ...] = (
|
|
220
|
+
ToolSpec(
|
|
221
|
+
name="list_capabilities",
|
|
222
|
+
description=(
|
|
223
|
+
"List the device's runtime capabilities (mirrors /hello). "
|
|
224
|
+
"Returns the capability schema array; each entry exposes "
|
|
225
|
+
"resources/commands reachable via invoke_command/read_resource."
|
|
226
|
+
),
|
|
227
|
+
input_schema=_DEVICE_ID_PARAM,
|
|
228
|
+
),
|
|
229
|
+
ToolSpec(
|
|
230
|
+
name="invoke_command",
|
|
231
|
+
description=(
|
|
232
|
+
"Forward an arbitrary capability command (unknown-capability "
|
|
233
|
+
"fallback). Pass capability_id + command_path segments + args "
|
|
234
|
+
"object; the phone's response is returned verbatim."
|
|
235
|
+
),
|
|
236
|
+
input_schema={
|
|
237
|
+
"type": "object",
|
|
238
|
+
"properties": {
|
|
239
|
+
"device_id": {"type": "string"},
|
|
240
|
+
"capability_id": {"type": "string"},
|
|
241
|
+
"command_path": {
|
|
242
|
+
"type": "array",
|
|
243
|
+
"items": {"type": "string"},
|
|
244
|
+
"description": "URL path segments, e.g. [virtual, connect].",
|
|
245
|
+
},
|
|
246
|
+
"args": {
|
|
247
|
+
"type": ["object", "null"],
|
|
248
|
+
"description": "Request body (object) or null.",
|
|
249
|
+
"additionalProperties": True,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
"required": ["device_id", "capability_id", "command_path"],
|
|
253
|
+
"additionalProperties": False,
|
|
254
|
+
},
|
|
255
|
+
),
|
|
256
|
+
ToolSpec(
|
|
257
|
+
name="read_resource",
|
|
258
|
+
description=(
|
|
259
|
+
"Read an arbitrary capability resource (unknown-capability "
|
|
260
|
+
"fallback). Pass capability_id + resource_path segments; the "
|
|
261
|
+
"phone's response is returned verbatim."
|
|
262
|
+
),
|
|
263
|
+
input_schema={
|
|
264
|
+
"type": "object",
|
|
265
|
+
"properties": {
|
|
266
|
+
"device_id": {"type": "string"},
|
|
267
|
+
"capability_id": {"type": "string"},
|
|
268
|
+
"resource_path": {
|
|
269
|
+
"type": "array",
|
|
270
|
+
"items": {"type": "string"},
|
|
271
|
+
},
|
|
272
|
+
"params": {
|
|
273
|
+
"type": ["object", "null"],
|
|
274
|
+
"additionalProperties": True,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
"required": ["device_id", "capability_id", "resource_path"],
|
|
278
|
+
"additionalProperties": False,
|
|
279
|
+
},
|
|
280
|
+
),
|
|
281
|
+
ToolSpec(
|
|
282
|
+
name="get_state",
|
|
283
|
+
description=(
|
|
284
|
+
"Read the device's aggregated state (mobile /state endpoint)."
|
|
285
|
+
),
|
|
286
|
+
input_schema=_DEVICE_ID_PARAM,
|
|
287
|
+
),
|
|
288
|
+
ToolSpec(
|
|
289
|
+
name="subscribe_events",
|
|
290
|
+
description=(
|
|
291
|
+
"Subscribe to the device's event stream (mobile /events SSE). "
|
|
292
|
+
"Optional event_types filter restricts which event types are "
|
|
293
|
+
"yielded."
|
|
294
|
+
),
|
|
295
|
+
input_schema={
|
|
296
|
+
"type": "object",
|
|
297
|
+
"properties": {
|
|
298
|
+
"device_id": {"type": "string"},
|
|
299
|
+
"event_types": {
|
|
300
|
+
"type": "array",
|
|
301
|
+
"items": {"type": "string"},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
"required": ["device_id"],
|
|
305
|
+
"additionalProperties": False,
|
|
306
|
+
},
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
#: Device-management tools (design §4.2.1). Always present; they let the AI
|
|
311
|
+
#: discover/register/list devices even before any phone app is reachable.
|
|
312
|
+
_DEVICE_MGMT_TOOLS: tuple[ToolSpec, ...] = (
|
|
313
|
+
ToolSpec(
|
|
314
|
+
name="list_devices",
|
|
315
|
+
description="List all devices currently known to the pool.",
|
|
316
|
+
input_schema={
|
|
317
|
+
"type": "object",
|
|
318
|
+
"properties": {},
|
|
319
|
+
"additionalProperties": False,
|
|
320
|
+
},
|
|
321
|
+
),
|
|
322
|
+
ToolSpec(
|
|
323
|
+
name="discover_devices",
|
|
324
|
+
description=(
|
|
325
|
+
"Trigger active USB + LAN device discovery and cross-identify "
|
|
326
|
+
"candidates into the pool. Set force=True to bypass the cache."
|
|
327
|
+
),
|
|
328
|
+
input_schema={
|
|
329
|
+
"type": "object",
|
|
330
|
+
"properties": {
|
|
331
|
+
"force": {"type": "boolean", "default": False},
|
|
332
|
+
},
|
|
333
|
+
"additionalProperties": False,
|
|
334
|
+
},
|
|
335
|
+
),
|
|
336
|
+
ToolSpec(
|
|
337
|
+
name="register_device",
|
|
338
|
+
description=(
|
|
339
|
+
"Register a device manually by host (fallback when discovery "
|
|
340
|
+
"can't find it). Probes /hello for the runtime label."
|
|
341
|
+
),
|
|
342
|
+
input_schema={
|
|
343
|
+
"type": "object",
|
|
344
|
+
"properties": {
|
|
345
|
+
"host": {"type": "string"},
|
|
346
|
+
"port": {"type": "integer", "default": 18080},
|
|
347
|
+
"label": {"type": "string"},
|
|
348
|
+
},
|
|
349
|
+
"required": ["host"],
|
|
350
|
+
"additionalProperties": False,
|
|
351
|
+
},
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# Service — CapabilityMirror
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class CapabilityMirror:
|
|
362
|
+
"""Dynamic capability mirror: /hello → tool manifest (design §3.4 / §5.2).
|
|
363
|
+
|
|
364
|
+
The mirror is **stateless w.r.t. IPs** — every :meth:`refresh` and
|
|
365
|
+
:meth:`build_tools` call that needs /hello asks :class:`BridgeClient.hello`
|
|
366
|
+
(which itself asks the pool per call). The only state held here is the
|
|
367
|
+
parsed schema cache used for change detection (§5.5 ③).
|
|
368
|
+
|
|
369
|
+
Lifecycle:
|
|
370
|
+
* :meth:`refresh(device_id)` — re-fetch /hello, rebuild cache, signal
|
|
371
|
+
whether the manifest changed (for list_changed). Idempotent; safe
|
|
372
|
+
to call on a poll timer.
|
|
373
|
+
* :meth:`schemas(device_id)` — read the cached schemas (no I/O).
|
|
374
|
+
* :meth:`build_tools(device_id)` — snapshot-rebuild the manifest:
|
|
375
|
+
static meta + device-mgmt + dynamic (semantic sugar / unknown-cap
|
|
376
|
+
no-op fallback). ``device_id=None`` or no cache → static only
|
|
377
|
+
(AC8 degrade path).
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
client: BF008 BridgeClient — the /hello probe goes through here.
|
|
381
|
+
providers: optional list of :class:`SemanticProvider` (BF010
|
|
382
|
+
registers gamepad here). ``None`` ⇒ no semantic sugar at all
|
|
383
|
+
(every known capability degrades to the meta invoke_command).
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
def __init__(
|
|
387
|
+
self,
|
|
388
|
+
client: BridgeClient,
|
|
389
|
+
*,
|
|
390
|
+
providers: list[SemanticProvider] | None = None,
|
|
391
|
+
) -> None:
|
|
392
|
+
self._client = client
|
|
393
|
+
# Snapshot list — providers are registered at construction. BF010
|
|
394
|
+
# will inject its gamepad instance; BF009's own tests use a stub.
|
|
395
|
+
self._providers: list[SemanticProvider] = list(providers or [])
|
|
396
|
+
# device_id → parsed schema snapshot. Runtime-only, never persisted
|
|
397
|
+
# (design §4.1 note: capability schema is CapabilityMirror's runtime
|
|
398
|
+
# mirror, NOT a DeviceRecord field).
|
|
399
|
+
self._cache: dict[str, list[CapabilitySchema]] = {}
|
|
400
|
+
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
# Provider registry (BF010 registration hook)
|
|
403
|
+
# ------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
def register_provider(self, provider: SemanticProvider) -> None:
|
|
406
|
+
"""Append a :class:`SemanticProvider` (BF010 registers gamepad here).
|
|
407
|
+
|
|
408
|
+
Idempotent in identity (no duplicate insert) but not in behavior:
|
|
409
|
+
registering after the first :meth:`build_tools` call does NOT
|
|
410
|
+
retroactively refresh the manifest — call :meth:`refresh` to pick up
|
|
411
|
+
the new provider.
|
|
412
|
+
"""
|
|
413
|
+
if provider not in self._providers:
|
|
414
|
+
self._providers.append(provider)
|
|
415
|
+
|
|
416
|
+
# ------------------------------------------------------------------
|
|
417
|
+
# /hello probe + change detection (design §5.5 ③)
|
|
418
|
+
# ------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
def refresh(self, device_id: str) -> bool:
|
|
421
|
+
"""Re-probe /hello, rebuild cache, return True if manifest changed.
|
|
422
|
+
|
|
423
|
+
Used by BF011 on a poll timer to drive ``notifications/tools/list_changed``
|
|
424
|
+
(design §5.5 B/C). /hello failures are **degrade-only**: the cache is
|
|
425
|
+
cleared and ``False`` is returned (no spurious list_changed). The next
|
|
426
|
+
:meth:`build_tools` will fall back to static-only so the AI is never
|
|
427
|
+
lied to about a phone that's actually offline.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
True iff the parsed schema differs from the cached snapshot
|
|
431
|
+
(including "no cache → cache populated", which IS a change).
|
|
432
|
+
False if /hello failed (cache cleared) or the schema is identical.
|
|
433
|
+
"""
|
|
434
|
+
try:
|
|
435
|
+
target = self._client.hello(device_id)
|
|
436
|
+
except (DeviceUnreachable, DeviceStale, DeviceHttpError, BridgeError):
|
|
437
|
+
# Any /hello failure → degrade. Clear cache so build_tools falls
|
|
438
|
+
# to static-only and a later successful refresh re-signals the
|
|
439
|
+
# transition back. We do NOT report a change here: list_changed
|
|
440
|
+
# signals "the manifest grew/shrank", and going from "had tools"
|
|
441
|
+
# to "static only" is a real change — but only if we actually had
|
|
442
|
+
# a non-empty manifest before. Reporting False on a cache→empty
|
|
443
|
+
# transition would be a lie, so we surface it honestly.
|
|
444
|
+
had_cache = device_id in self._cache
|
|
445
|
+
self._cache.pop(device_id, None)
|
|
446
|
+
# Transition from "had dynamic tools" to "static only" IS a change
|
|
447
|
+
# the AI client needs to know about (its cached tools are stale).
|
|
448
|
+
return had_cache
|
|
449
|
+
|
|
450
|
+
new_schemas = self._parse_schemas(target)
|
|
451
|
+
old_schemas = self._cache.get(device_id)
|
|
452
|
+
changed = old_schemas != new_schemas
|
|
453
|
+
if changed:
|
|
454
|
+
self._cache[device_id] = new_schemas
|
|
455
|
+
return changed
|
|
456
|
+
|
|
457
|
+
# ------------------------------------------------------------------
|
|
458
|
+
# Cache access (no I/O)
|
|
459
|
+
# ------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
def schemas(self, device_id: str) -> list[CapabilitySchema]:
|
|
462
|
+
"""Return the cached schemas for ``device_id`` (empty list if none).
|
|
463
|
+
|
|
464
|
+
Does NOT probe /hello — call :meth:`refresh` first. Returns a *copy*
|
|
465
|
+
so the caller can't mutate the cache via the returned reference.
|
|
466
|
+
"""
|
|
467
|
+
cached = self._cache.get(device_id)
|
|
468
|
+
return list(cached) if cached else []
|
|
469
|
+
|
|
470
|
+
# ------------------------------------------------------------------
|
|
471
|
+
# Manifest rebuild (design §5.5 ① snapshot rebuild)
|
|
472
|
+
# ------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
def build_tools(self, device_id: str | None = None) -> list[ToolSpec]:
|
|
475
|
+
"""Snapshot-rebuild the tool manifest for ``device_id``.
|
|
476
|
+
|
|
477
|
+
Combines (in order, so semantic-sugar names never collide with meta
|
|
478
|
+
because BF010 owns its naming namespace):
|
|
479
|
+
|
|
480
|
+
1. static meta tools (always present, design §4.2.2)
|
|
481
|
+
2. device-management tools (always present, design §4.2.1)
|
|
482
|
+
3. dynamic semantic-sugar tools (only when a provider matches)
|
|
483
|
+
|
|
484
|
+
Unknown-capability fallback (design §5.2 / AC10): a parsed capability
|
|
485
|
+
with no matching provider contributes NO extra tool — it stays
|
|
486
|
+
reachable only via the meta ``invoke_command``/``read_resource``
|
|
487
|
+
tools. This guarantees the AI can drive any capability even before
|
|
488
|
+
BF010 lands a dedicated sugar for it.
|
|
489
|
+
|
|
490
|
+
``device_id=None`` or no cached schema → static meta + device-mgmt
|
|
491
|
+
only (the AC8 degrade path: AI learns there's nothing to operate on
|
|
492
|
+
and can discover/register first).
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
A new list each call (snapshot — caller owns it). Tool names are
|
|
496
|
+
unique within the result by construction.
|
|
497
|
+
"""
|
|
498
|
+
tools: list[ToolSpec] = list(_STATIC_META_TOOLS) + list(_DEVICE_MGMT_TOOLS)
|
|
499
|
+
|
|
500
|
+
if device_id is None:
|
|
501
|
+
return tools
|
|
502
|
+
|
|
503
|
+
cached = self._cache.get(device_id)
|
|
504
|
+
if not cached:
|
|
505
|
+
# No prior refresh (or last refresh failed) → degrade to static.
|
|
506
|
+
# We do NOT implicitly probe /hello here: build_tools is called
|
|
507
|
+
# on every tools/list and must stay cheap + never surprise the
|
|
508
|
+
# caller with a network round-trip. refresh() owns the probe.
|
|
509
|
+
return tools
|
|
510
|
+
|
|
511
|
+
for schema in cached:
|
|
512
|
+
tools.extend(self._build_sugar(schema))
|
|
513
|
+
return tools
|
|
514
|
+
|
|
515
|
+
def _build_sugar(self, schema: CapabilitySchema) -> list[ToolSpec]:
|
|
516
|
+
"""Ask registered providers for semantic-sugar tools for one schema.
|
|
517
|
+
|
|
518
|
+
First-matching-provider wins (BF010 gamepad is the only realistic
|
|
519
|
+
provider this iteration). If no provider matches, the schema is
|
|
520
|
+
treated as "unknown capability" and contributes no tools (the D3
|
|
521
|
+
fallback: reachable only via meta invoke_command/read_resource).
|
|
522
|
+
"""
|
|
523
|
+
for provider in self._providers:
|
|
524
|
+
if provider.matches(schema):
|
|
525
|
+
return provider.build_tools(schema)
|
|
526
|
+
return []
|
|
527
|
+
|
|
528
|
+
# ------------------------------------------------------------------
|
|
529
|
+
# /hello → CapabilitySchema parsing (design §4.1 CapabilitySchema)
|
|
530
|
+
# ------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
def _parse_schemas(self, target: NetworkTarget) -> list[CapabilitySchema]:
|
|
533
|
+
"""Parse the phone's runtime capability schema into CapabilitySchema[].
|
|
534
|
+
|
|
535
|
+
平面零 gamepad 业务知识(AD-B1)。两条路径:
|
|
536
|
+
|
|
537
|
+
* **新 app**(FF002):``target.registered_capabilities`` 是 tuple
|
|
538
|
+
of dicts,每 dict 形如 ``{id, resources[], commands[]}``,逐个
|
|
539
|
+
解析为 :class:`CapabilitySchema`(description 可选)。
|
|
540
|
+
* **Legacy app**(pre-FF002):``registered_capabilities`` 为 None,
|
|
541
|
+
对 ``target.capabilities`` 每 tag 生成 **sentinel**
|
|
542
|
+
:class:`CapabilitySchema`(``capability_id=tag``, 空
|
|
543
|
+
``resources``/``commands``),交 :meth:`SemanticProvider.matches`
|
|
544
|
+
认领(E 方案 OI-B1 resolved)。平面不知 ``virtual_input``→
|
|
545
|
+
``gamepad`` 映射(业务知识,业务 provider 内部按 tag 认领)。
|
|
546
|
+
* 两条信号均缺 → 空 schema list(build_tools 降级到 static-only)。
|
|
547
|
+
"""
|
|
548
|
+
registered = target.registered_capabilities
|
|
549
|
+
if registered is not None:
|
|
550
|
+
return [
|
|
551
|
+
self._parse_one(cap)
|
|
552
|
+
for cap in registered
|
|
553
|
+
if isinstance(cap, dict)
|
|
554
|
+
]
|
|
555
|
+
|
|
556
|
+
# Legacy degrade path: E 方案 sentinel(AD-B1,平面零 gamepad 业务知识)。
|
|
557
|
+
# 对 target.capabilities 每 tag 生成 sentinel CapabilitySchema,
|
|
558
|
+
# 交 provider.matches(schema) 认领(业务 provider 内部按 tag→capability
|
|
559
|
+
# 映射产 tools)。
|
|
560
|
+
if target.capabilities:
|
|
561
|
+
return [
|
|
562
|
+
CapabilitySchema(
|
|
563
|
+
capability_id=tag,
|
|
564
|
+
resources=(),
|
|
565
|
+
commands=(),
|
|
566
|
+
description=None,
|
|
567
|
+
)
|
|
568
|
+
for tag in target.capabilities
|
|
569
|
+
]
|
|
570
|
+
return []
|
|
571
|
+
|
|
572
|
+
@staticmethod
|
|
573
|
+
def _parse_one(cap: JsonMap) -> CapabilitySchema:
|
|
574
|
+
"""Parse a single registeredCapabilities entry into CapabilitySchema.
|
|
575
|
+
|
|
576
|
+
Defensive against malformed entries: a missing/non-string ``id`` yields
|
|
577
|
+
an empty-id schema (which won't match any provider, so it degrades to
|
|
578
|
+
the unknown-capability fallback). Resources/commands lists default to
|
|
579
|
+
empty when missing or malformed.
|
|
580
|
+
"""
|
|
581
|
+
cap_id_raw = cap.get("id")
|
|
582
|
+
cap_id = cap_id_raw if isinstance(cap_id_raw, str) and cap_id_raw else ""
|
|
583
|
+
|
|
584
|
+
resources = _parse_decls(cap.get("resources"), ResourceDecl)
|
|
585
|
+
commands = _parse_decls(cap.get("commands"), CommandDecl)
|
|
586
|
+
description_raw = cap.get("description")
|
|
587
|
+
description = (
|
|
588
|
+
description_raw if isinstance(description_raw, str) and description_raw else None
|
|
589
|
+
)
|
|
590
|
+
return CapabilitySchema(
|
|
591
|
+
capability_id=cap_id,
|
|
592
|
+
resources=resources,
|
|
593
|
+
commands=commands,
|
|
594
|
+
description=description,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# ---------------------------------------------------------------------------
|
|
599
|
+
# Module-level parsing helpers (pure functions for testability)
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _parse_decls(
|
|
604
|
+
raw: Any,
|
|
605
|
+
decl_cls: type[ResourceDecl] | type[CommandDecl],
|
|
606
|
+
) -> tuple[ResourceDecl, ...] | tuple[CommandDecl, ...]:
|
|
607
|
+
"""Parse a resources[]/commands[] list into frozen decl tuples.
|
|
608
|
+
|
|
609
|
+
Each entry is expected to be ``{method, path, ?description}``. ``method``
|
|
610
|
+
and ``path`` are required and coerced defensively: a non-string method or
|
|
611
|
+
non-list path skips the entry (rather than raising) so one bad entry in
|
|
612
|
+
the phone's payload can't crash the whole manifest build. ``description``
|
|
613
|
+
is optional and stays ``None`` when absent or non-string.
|
|
614
|
+
|
|
615
|
+
The decl_cls argument selects between ResourceDecl and CommandDecl so the
|
|
616
|
+
same logic parses both; mypy can't follow the union return, so callers
|
|
617
|
+
treat the result as a sequence of the matching decl type.
|
|
618
|
+
"""
|
|
619
|
+
if not isinstance(raw, list):
|
|
620
|
+
return () # type: ignore[return-value]
|
|
621
|
+
out: list[Any] = []
|
|
622
|
+
for item in raw:
|
|
623
|
+
if not isinstance(item, dict):
|
|
624
|
+
continue
|
|
625
|
+
method = item.get("method")
|
|
626
|
+
path = item.get("path")
|
|
627
|
+
if not isinstance(method, str) or not isinstance(path, list):
|
|
628
|
+
continue
|
|
629
|
+
description = item.get("description")
|
|
630
|
+
desc = description if isinstance(description, str) and description else None
|
|
631
|
+
out.append(decl_cls(method=method, path=tuple(str(p) for p in path), description=desc))
|
|
632
|
+
return tuple(out) # type: ignore[return-value]
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
__all__ = [
|
|
636
|
+
"CapabilityMirror",
|
|
637
|
+
"CapabilitySchema",
|
|
638
|
+
"CommandDecl",
|
|
639
|
+
"ResourceDecl",
|
|
640
|
+
"SemanticProvider",
|
|
641
|
+
"ToolSpec",
|
|
642
|
+
]
|