Skip to content

[Feature and Refactor] Hardware Context Protocol - #45

Merged
wangxingjun778 merged 9 commits into
mainfrom
feat/mhs
Sep 7, 2026
Merged

[Feature and Refactor] Hardware Context Protocol#45
wangxingjun778 merged 9 commits into
mainfrom
feat/mhs

Conversation

@wangxingjun778

Copy link
Copy Markdown
Member

Adds a governed path for operating physical devices, an observable hardware plane in LeapBoard, and low-latency device preview.

Feature

  • Hardware Context Protocol (hc.v0)HardwareContext declares what an agent must know to command a device safely (quantities, units, operating envelopes, interlocks, settling time, reversibility); HardwareTransport is a six-method southbound contract that can be swapped freely. Two kind → factory seams make supporting another standard a module plus a table row.
  • Eight generic hardware tools, count independent of device count. Writes split by effect class so configure / actuate / dispense each reach their own ActionKind.
  • Transports: mock, simulated, Python callable, MCP, host metrics, local media. Providers: YAML declarations, host metrics, local media enumeration.
  • LeapBoard hardware lens — fleet inventory, device pages, channel traces, envelope conformance, sampling and calibration panels, controls derived from the declared envelope, 6-language i18n.
  • Low-latency device preview — latest-frame FFmpeg pump → daemon preview stream → binary WebSocket relay → browser canvas, latest-only. Viewer-owned leases with explicit release; Economy / Balanced / Detail profiles up to 1280px @ 30fps.
  • Microphone level meter — privacy-gated scalar preview drawn as a browser-local waveform, never persisted as audio.
  • Calibration — per-channel state, freshness and residual correction, with store, board section and leap hw commands.
  • leap hw CLI — list, describe, read, status, estop, plus daemon-routed pause/resume.
  • Trust, replay and audit surfaces for physical commands; physical observability digest, series and exporter.

Enhance

  • Reachability precedes consent — a device that was never reachable no longer produces a prompt, a consent, and only then the failure.
  • Grant identity is device:channel@envelope-band — one consent covers the band; widening the declared envelope invalidates the narrower grant.
  • Envelope-derived hardline denials (out of range, unsatisfied interlock, undeclared envelope, unverified context, effect-class mismatch). Rate limiting is a retryable refusal, not a hardline.
  • Physical writes declare effect_scope=external — a failed command is not replayed, and its side-effect verdict reaches the next turn.
  • Sampling — deadline-scheduled (no drift), persistence moved off the sampling path, per-kind event pacing, envelope-derived threshold hysteresis, per-device I/O mutex.
  • Raw readings stay out of SignalBuffer; only envelope-derived events enter the signal pipeline. Raw samples persist session-scoped, sensitive, non-syncable and TTL-bounded.
  • Envelope gains tolerance and settling_model / settling_tau_s, both driven by falsified experiment predictions and both defaulting to prior behaviour, so hc.v0 declarations are unaffected.
  • Board generation is atomic — a content revision moves Python, templates and static assets as one, and a stale server is replaced rather than reused.
  • Preview panels report the live lease (active, viewers, frame_age_ms), which is the only signal that distinguishes a bound declaration from a device actually capturing.

Fix

  • Security: MCP tools could execute ungated — a pre-existing approval bypass.
  • Every Board view returned HTTP 503 — the hardware digest shipped an unrendered per-window conformance grid that grew one finding to ~980 KB and overran the 4 MiB JSON-RPC frame. Bounded the payload and the watch.findings reply, typed the transport error, and scoped fetching per domain.
  • Reading persisted a monotonic timestamp as wall-clock, making stored history unreadable across restarts and incomparable with any other subsystem. Split into observed_at and monotonic_at.
  • HCP consistency closure (D1–D7) — an unleased read left a camera claimed for the life of the daemon; leap hw never closed the registry it built; the frame read bypassed the per-device I/O lock; the board could not report real capture; the render contract carried device-class vocabulary; a template bound a field the builder never supplied; probe/halt left their lock-free contract unstated.

Documentation

  • New: peripheral plane and board specification, hardware init/calibration support.
  • Updated: AGENTS.md, third-party plugin development guide.

Tests

  • ~20 new hardware and dashboard suites covering context, transport conformance, governance, media, observability, reading store, streaming, outcomes, replay/audit, longevity, MCP governance, and board SDUI/i18n/launcher/view.
  • Full suite: 3273 passed, 4 skipped, 1 xfailed.

Adds leapflow.hardware, a governed path for operating physical devices, and
fixes a pre-existing security defect that let MCP tools execute ungated.

Hardware Context Protocol (hc.v0)
---------------------------------
Splits device integration along what can be known. HardwareContext describes
what an agent must know to command a device safely -- quantities, units,
operating envelopes, interlocks, settling time, reversibility -- and is fixed by
physics rather than by any wire protocol. HardwareTransport is a six-method
contract for how a command reaches the device, and can be swapped freely. Two
kind->factory seams (providers/, transports/) make supporting another southbound
standard a module plus a table row; an architecture contract test keeps upstream
standard concepts out of the domain model so that stays true.

- Eight generic tools, count independent of device count (progressive
  disclosure). Writes split by effect class so each reaches its own ActionKind
  instead of a generic fallback.
- Envelope-derived hardline denials: out of range, unsatisfied or unevaluable
  interlock, undeclared envelope, unverified context, effect-class mismatch.
  Rate limiting is a retryable refusal, not a hardline: the same command becomes
  valid after waiting.
- Physical writes declare effect_scope=external, so a failed command is not
  replayed and its side-effect verdict reaches the next turn. Re-running an
  aspirate dispenses twice; an error is not proof that nothing happened.
- Grant identity is device:channel@envelope-band. One consent covers the band,
  and widening the declared envelope invalidates the narrower grant it was
  given under.
- Sampling keeps raw readings out of SignalBuffer (capacity 50); only
  envelope-derived events cross into the interaction signal pipeline.
- Raw samples persist as session-scoped, sensitive, non-syncable, TTL-bounded
  cache artifacts; downsampled windows go to instrument.duckdb.
- Physical outcomes compute a numeric, envelope-normalised delta with no model
  call and land in ExperienceStore, so an optimisation performed once becomes a
  starting point rather than an experiment to repeat.

Off by default. With hardware disabled the plugin exposes no tools and the risk
classifier is the unmodified default, leaving the tool index byte-identical.

MCP approval bypass (security)
------------------------------
MCP tool schemas carried no x_leapflow metadata and the handler was a bare
passthrough, so third-party server code ran with this agent's privileges with no
risk classification, no consent, and no audit record -- the only sensitive
capability in the process reachable without the orchestrator.

- ActionKind.MCP_TOOL, assessed on provenance rather than on the description the
  server wrote about itself.
- The handler routes through ApprovalOrchestrator, resolved per call so the
  daemon-side gate applies. Fails closed on absence and on exception.
- readOnlyHint is honoured where present; its absence is not a safety claim, so
  unannotated servers stay gated.
- Descriptions matching prompt-injection patterns are refused registration
  rather than logged, because they are injected verbatim into the tool index.
- mcp.approval_mode: mutating_only (default) | always | off.

Reviewer note: security/actions.py, security/risk.py, cli/context.py and the two
config modules carry hunks from both concerns, so splitting at hunk granularity
risked a non-building intermediate commit. The security-module surface worth
reviewing on its own is the six new ActionKind values, ActionDescriptor.device()
and .mcp_tool(), and the two _normalize_detail branches -- together they define
the grant contract for both domains.

Config: mcp.approval_mode plus 11 hardware.* keys, all discoverable through
leap config and all restart-required.

Tests: 2708 passed, 2 skipped, 1 xfailed (+142); journeys 7 passed with cassette
fingerprints unchanged; ruff clean on new and modified files.
Continues leapflow.hardware from a governed command path into an observable
one, adds the second southbound transport, and fixes several defects that were
green in the suite because nothing asserted the connection they broke.

Clock domains and sampling
--------------------------
Reading carried one timestamp taken from time.monotonic() and persisted it as a
wall-clock instant, so stored history was unreadable across restarts and could
not be compared with any other subsystem. Split into observed_at (wall) and
monotonic_at (per-boot), mirroring the convention SystemEvent already declared.

- Sampling schedules against a deadline instead of sleeping a fixed interval, so
  a slow read no longer accumulates drift; missed slots are counted, not hidden.
- Persistence moved off the sampling path via asyncio.to_thread.
- Per-kind event pacing, so a paced rate_exceeded cannot hide a first-time
  threshold_exceeded behind it.
- Threshold hysteresis derived from the declared envelope quantization rather
  than a new knob: a value hovering on a bound no longer emits a breach per
  sample. Breach uses the human's limit exactly; recovery uses the margin.
- Per-device I/O mutex. An interleaved read and write on one bus lands a command
  on the wrong channel, and that outcome is indistinguishable from success.

Reachability precedes consent
-----------------------------
registry.transport() ran after approval, so commanding a device that was never
reachable produced a prompt, a consent, and only then the failure. Asking
somebody to authorise an undeliverable command is how people learn to click
through prompts -- and the prompt they learn to dismiss guards the commands that
can be delivered. The check probes rather than only opens, because transport()
caches: a session that died is handed back without open() running again, which
is the common failure for a server-backed device. A dead transport is dropped so
the refusal cannot outlive the outage. hw_estop is deliberately exempt: refusing
to attempt a stop is worse than attempting one that fails.

MCP transport
-------------
transports/mcp.py is the second southbound implementation and therefore the
first real test of the seam's claim. Measured: one new module, one lookup row,
and sixteen lines in cli/context.py to install the client resolver -- with zero
lines changed in the domain model. The third file is specific to MCP being
already present in the process as a control plane; a self-contained standard
would not need it.

Everything device-specific is declared: tool names, argument names, the response
key holding the value. No name matching and no fallback chain, because a guess
that lands on the wrong tool is a physical action nobody authorised and nothing
downstream can detect it. A failed write reports UNKNOWN unless the server says
otherwise -- the MCP client turns a timeout into an ordinary error reply, so a
failure genuinely cannot distinguish "never sent" from "sent, no answer".

The conformance suite hardcoded _TRANSPORT_CASES[0] for its two most
consequential cases -- an error is not proof that nothing happened, and "cannot
stop" must be declared -- so every transport after the first was covered by
fourteen cases and silently exempt from those two. Now parameterised per case.

Storage governance
------------------
reading_windows had no retention, no index, and no way to remove the rows whose
timebase was unusable. Retention runs on the write path, so the only process
that grows the table is the one that trims it. Raw files roll at a byte cap and
are re-indexed per append: CacheManager records the size it finds at
registration, so a file indexed once and appended to for hours was accounted at
its first few kilobytes with a TTL counting down from the first sample.

LeapBoard
---------
New hardware lens: device inventory, channel traces, envelope conformance,
sampling health, learned command outcomes, storage state. Two panels
deliberately expose problems rather than hiding them -- observed-versus-declared
rate makes sampling drift visible, and unpersisted-window count makes a locked
database visible, since a database that cannot be opened otherwise looks exactly
like an idle bench.

- capability lens rendered nothing: its producer set only evidence, never
  payload, which is what the template binds to. Four tables also used a mapping
  repeat and a node-level bind, both of which render_node ignores. Correct
  headings over zero rows, with nothing reporting a fault.
- Board i18n covered only the two original lenses. Five of seven shipped English
  in every language -- capability 0 of 31 strings, hardware 14 of 44 -- while the
  i18n test checked signal keys and stayed green. 100 strings x 5 locales added,
  with coverage now asserted per template and per locale.
- /board gained completion for its second token. It accepts a reserved verb or a
  lens name in the same position and offered neither, so both were reachable
  only by reading the source.

Physical learning
-----------------
predicted_effect was always "reach <commanded>", so the residual measured the
device and could never improve: a valve that always ran eight percent low
reported the same error on its thousandth command as its first. Outcomes now
carry a second residual measured against a prediction derived from prior
observations, which is the only one a learning loop can drive down. No LLM call
-- the physical predictor is the command plus a learned bias, clamped to a
quarter of the declared span. The calibration is per session and says so.

Concurrency
-----------
Seven stores captured holder.connection in __init__, permanently binding the
root connection and defeating the per-thread cursor mechanism entirely. Two
threads then reached DuckDB concurrently and the daemon hung before answering
its first status call -- 37% of runs, reproduced against HEAD. Now resolved per
call. daemon.status also read the watch store inline on the event loop; that
read is now serialized off-loop.

Execution policy
----------------
x_leapflow.risk_level carries seven values (a disclosure grading) while
ToolSpec.RiskLevel has three (a side-effect classification). plugin_generate
declared medium and was parsed as read_only, so the ledger deduplicated it, it
ran freely in parallel, and no side-effect gate applied. An explicit no-effect
claim now outranks a substring guess about the name; exactly one built-in tool
changes behaviour.

Also: PluginHealthProducer registered (its docstring claimed it already was),
interval watch dedup keys fixed, sync_fixtures --check no longer fails on corpus
growth, AGENTS.md gains the plugin and extension rules.

Verified: 2799 passed, 55 regression, 7 journeys, i18n coverage complete at 7
templates x 5 locales. Static analysis unchanged from baseline.
…on and trust

Folds the first real findings from the physical experiment workspace back into the
domain model, and adds the calibration, trust, replay and audit surfaces the
experiments showed were missing.

Envelope revisions, both driven by a falsified prediction
---------------------------------------------------------
Two gaps were confirmed by experiment rather than by reasoning, and both changed
what a stored delta means:

- G-1 (E3-T0, ratio 100 where ~1 was predicted): bias is an absolute quantity and
  does not scale with the declared range, so span-normalisation made a
  tight-tolerance channel report a misleadingly small error. Envelope gains
  `tolerance`, and normalized_delta divides by it when declared.
- G-2 (E2-T0, 95% convergence at 15s against a 2-tau budget of 10s, 12.3%
  residual): a single settling instant cannot describe an asymptotic approach.
  Envelope gains `settling_model` and `settling_tau_s`, with
  `effective_settling_s` giving 5*tau for first-order channels; record_command
  now waits on that rather than on a scalar.

Both fields default to prior behaviour (`tolerance=0.0`, `settling_model="step"`),
so hc.v0 declarations are unaffected and HC_VERSION does not move. The promotion
threshold for hc.v1 is now met on evidence (four confirmed gaps across host,
bench and driver classes), but bumping the version would force every existing
declaration to be re-examined for an additive, optional change -- so the schema
was extended instead. That call is worth a second look on review.

New capability
--------------
- Calibration: per-channel state, freshness and residual correction, with a
  store, a board section, and `leap hw` commands
- Trust and readiness: a device that cannot be commanded is refused before a
  human is asked for consent, via `blocks_approval` -- prompting for permission
  to an action that will be denied teaches users to click through prompts
- Replay and audit: device sessions can be replayed from recorded readings, and
  commands leave an audit trail
- Transport discovery, an in-repo simulated transport, and a testing helper
- r8_hardware journey with cassettes

Security surface
----------------
`allow_permanent=False` is now enforced rather than advisory: the gate withholds
session-wide bypass for actions whose risk forbids a permanent grant. Previously
the flag was a suggestion no code checked.

i18n
----
The calibration board section shipped eight English literals with no translation
in any of the five non-English locales. `test_dashboard_i18n_static` caught it --
that test exists because five of seven boards once rendered English in every
language while the older signal-key check stayed green.

Tests: 3160 passed, 2 skipped, 1 xfailed (was 2708); journeys green including the
new r8_hardware; ruff clean on new and modified files.
Every Board view returned HTTP 503 "could not be assembled". The hardware
digest carried a per-window `conformance` grid that no renderer draws and
`_fit()` never decimated, growing one finding to ~980 KB. A `watch.findings`
batch then reached 8.33 MiB and overran the 4 MiB JSON-RPC frame, so the
generic, signals, hardware and capability lenses all failed together.

Fixed in depth, at each layer that let it through:

- series/digest: ship only the rendered `conformance_mix` distribution,
  classify conformance after series are clamped, and warn when a payload
  still exceeds the ceiling instead of returning oversize silently.
  Worst-case payload drops from ~980 KB to ~160 KB. Schema version -> 2.
- monitor_coordinator: bound a `watch.findings` reply to one RPC frame,
  keeping the newest findings and dropping the oldest tail.
- daemon client: translate an over-limit frame from a bare ValueError into
  a typed DaemonUnavailableError naming the limit and the responsible handler.
- dashboard service: fetch findings scoped to a domain's own watch, so a
  burst of large hardware findings cannot crowd out a current session or
  capability finding.

Also lands the MHS hardware preview work in progress: device probe and
preview across the Board, viewer-owned preview leases with explicit release,
an atomic Board content revision so templates, static assets and Python move
as one generation, and preview profile ceilings.

Verified in a real browser: all four lenses render, hardware shows its
device cards, channel traces, envelope conformance, sampling and calibration
panels. Adds regression coverage for the payload bound, the frame budget,
the typed transport error and domain-scoped fetching.

Signed-off-by: 班扬 <xingjun.wxj@alibaba-inc.com>
An audit asked whether the LeapBoard hardware plane routes scan, connect and
preview through the Hardware Context Protocol. The data paths do. Seven
deviations sat in lifecycle governance, concurrency and vocabulary instead,
and the suite was green through all of them.

Device occupation could be left running with no owner:

- MediaTransport.read() on a frame channel started a capture only close/halt
  could stop, so a single hw_read left a camera claimed for the life of the
  daemon -- no lease to sweep it, no release to call. read() now captures
  transiently and restores the claim it found; read_frame() stays the preview
  path whose capture the PreviewBroker lease owns. The ownership test, the
  capture and the stop share one lock hold, so a preview starting concurrently
  cannot have its grabber stopped by a metadata read.
- `leap hw` builds its own registry, which has no EffectScope and was never
  closed. The read plane now releases every transport in a finally, on the
  failure path too.

Concurrency and observability:

- The frame read runs inside the registry's per-device I/O lock, exactly as
  scalar reads, writes and the sampling loop do. MediaTransport's internal
  lock hid the gap in tree; a third-party FrameTransport sharing a bus with a
  scalar channel had no such cover.
- Preview rows carry the live lease (active, viewers, frame_age_ms). The
  Connection stat cannot report capture -- a media transport reports connected
  once its declaration is bound, deliberately before any capture -- so a camera
  with its light on and one merely resolved looked identical. Read through
  registry.active_previews(), which answers "nothing" without building a broker
  and with it a sweeper task.

Contract vocabulary:

- The render mode is the declared representation, passed through rather than
  translated into "camera"/"microphone": those name device classes the protocol
  deliberately never branches on, so a level source that was not a microphone
  arrived at the browser labelled one.
- Dropped the autostart binding the builder never supplied. Opening a camera
  follows a person asking, not a page loading.
- probe() and halt() now state their lock-free contract on the Protocol itself.
  A diagnostic must not queue behind the operation it diagnoses, and taking a
  lock in order to stop a moving machine delays the halt by exactly as long as
  the runaway operation takes.

docs/plugins/hardware_peripherals_board.md is a third-party specification and
had gone materially stale: it documented the MJPEG endpoint the binary
WebSocket relay replaced, a 12fps Detail profile, and silence as the only
release path. Updated to what the code does today.

Each new test was verified to fail against the pre-fix code. The first version
of the lock test passed regardless -- it counted lock acquisitions, and the
release path already takes that lock -- so it was rewritten to assert the lock
was held at the moment read_frame ran.

Full suite: 3273 passed, 4 skipped, 1 xfailed.

Signed-off-by: 班扬 <xingjun.wxj@alibaba-inc.com>
MILESTONE. The LeapBoard hardware plane now routes scan, connect and preview
through the Hardware Context Protocol with no ungoverned device claim, no
unserialised frame read, and no device-class vocabulary in the render contract.
All seven deviations from the HCP consistency audit are closed:

  D1  frame read serialised on the registry's per-device I/O lock
  D2  an unleased read releases the capture it started
  D3  `leap hw` releases every transport through HCP close before it exits
  D4  the board reports the live preview lease, not a bound declaration
  D5  render mode is the declared representation, not a device class
  D6  the autostart binding the builder never supplied is gone
  D7  probe/halt state their lock-free contract on the Protocol itself

Verified on real hardware: the indicator light follows the lease, an explicit
Stop releases the device at once rather than waiting for the idle sweep, and a
one-shot read leaves nothing claimed. Full suite: 3273 passed, 4 skipped,
1 xfailed.

No tag is cut; this commit is the milestone marker. __version__ moves to
0.2.1+main so the runtime self-report matches it.

Signed-off-by: 班扬 <xingjun.wxj@alibaba-inc.com>
@wangxingjun778
wangxingjun778 merged commit 1933f96 into main Sep 7, 2026
1 of 2 checks passed
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.

1 participant