|
| 1 | +# Engine Ports & Implementations |
| 2 | + |
| 3 | +The engine never depends on a concrete external service. Everything it needs |
| 4 | +from "outside" is reached through an **interface — a port — declared in |
| 5 | +`engine/port.go`**. Concrete adapters satisfy those interfaces and map to |
| 6 | +their own wire forms privately, so the engine core compiles and runs without |
| 7 | +knowing whether it's talking to the fh-backend, a local file, or nothing at |
| 8 | +all. |
| 9 | + |
| 10 | +There are two sources of implementation: |
| 11 | + |
| 12 | +- **The fh-backend adapter** (`engine/backend`) — one HTTP client that |
| 13 | + satisfies every port, used when `FH_BACKEND_URL` is configured. |
| 14 | +- **Built-in / standalone behavior** — what happens with no backend. For some |
| 15 | + ports that's a real local implementation; for others it's "the port is |
| 16 | + nil and the engine does without." |
| 17 | + |
| 18 | +`cmd/engine/main.go` is the only place that decides which adapter fills each |
| 19 | +seam, based on whether a backend client was constructed. |
| 20 | + |
| 21 | +## The matrix |
| 22 | + |
| 23 | +| Port | Methods | Required? | No backend (standalone) | fh-backend adapter | |
| 24 | +|------|---------|-----------|-------------------------|--------------------| |
| 25 | +| `LlmClient` | `Chat` | Required for agent nodes | Local providers via `llmproxy` (direct API keys) | Backend-routed provider fallback | |
| 26 | +| `Retriever` | `QueryRAG` | Required **only if** a retrieval node is deployed | **nil** → build rejects any Retriever node | Forwards to `/rag/query` | |
| 27 | +| `Supervisor` | `Register`, `Heartbeat` | Optional | **nil** → no registration/heartbeat | POSTs `/agents/bootCallback`, `/agents/heartbeat` | |
| 28 | +| `MemorySync` | `Hydrate`, `Push` | Optional (mirror only) | **nil** → local-only memory | HTTP `GET`/`PUT /agents/memory` | |
| 29 | + |
| 30 | +Two capabilities deliberately are **not** ports: |
| 31 | + |
| 32 | +- **Local memory persistence** — owned unconditionally by |
| 33 | + `engine/memory.Manager`. The device always has a durable local copy; see |
| 34 | + [Memory](#memorysync-optional-remote-mirror) below. |
| 35 | +- **Logging** — stderr is unconditional and the engine already depends on the |
| 36 | + `logging` package. Optional log shipping to the backend is a `logging` |
| 37 | + `HTTPWriter` wired by `main`, not a port. |
| 38 | + |
| 39 | +## LlmClient — required for agent nodes |
| 40 | + |
| 41 | +`Chat` is the chat-completion seam. The implementation is |
| 42 | +`llmproxy.Client`, which dispatches by model id across configured providers. |
| 43 | + |
| 44 | +- **Standalone:** providers configured with direct API keys |
| 45 | + (`anthropic`/`openai`/`gemini`/`mistral`/`selfhosted`). This is the primary |
| 46 | + path, not a fallback. |
| 47 | +- **Backend:** any provider the backend exposes but the engine has no local |
| 48 | + key for is registered as a backend-routed stand-in, resolved by model id |
| 49 | + exactly as if it were local. |
| 50 | +- **Missing:** an `AgentNode` with a nil `LlmClient` **fails the build** |
| 51 | + (`engine/build/graph.go`). There is no silent default. |
| 52 | + |
| 53 | +## Retriever — required only when used |
| 54 | + |
| 55 | +`QueryRAG` is the RAG seam. There is **no built-in standalone implementation |
| 56 | +yet**. |
| 57 | + |
| 58 | +- **Standalone:** `Retriever` is nil. A workflow that declares a Retriever |
| 59 | + node **fails to deploy** with a clear error, mirroring the WebSearch and |
| 60 | + Agent build checks. A workflow with no retrieval node deploys and runs |
| 61 | + fine. (This replaced a silent no-op that returned empty results.) |
| 62 | +- **Backend:** forwards to `/rag/query`. |
| 63 | +- **Planned:** a standalone pgvector-backed adapter (query embedding via |
| 64 | + `llmproxy` + similarity search + ingestion) will live in its own package |
| 65 | + (e.g. `engine/rag/pgvector`), not bundled with the trivial seams. |
| 66 | + |
| 67 | +## Supervisor — optional outbound callbacks |
| 68 | + |
| 69 | +`Supervisor` abstracts whoever receives this agent's callbacks: the |
| 70 | +registration sent at boot plus the periodic liveness heartbeat. It is a |
| 71 | +purely outbound seam — deploys/commands arrive the other way, through the |
| 72 | +engine's HTTP server. |
| 73 | + |
| 74 | +- **Standalone:** nil. With no one to report to, the engine simply doesn't |
| 75 | + register or heartbeat. This is correct, not degraded — pull-based health |
| 76 | + endpoints are the standalone observability story, not a fake heartbeat. |
| 77 | +- **Backend:** `backend.Client` POSTs `/agents/bootCallback` and |
| 78 | + `/agents/heartbeat`. The retry/heartbeat loops live in |
| 79 | + `engine/lifecycle.go`. |
| 80 | + |
| 81 | +## MemorySync — optional remote mirror |
| 82 | + |
| 83 | +Memory is **local-first (edge-primary)**. `engine/memory.Manager` owns a |
| 84 | +durable directory of `<uid>.json` records and is the source of truth: it |
| 85 | +reads them at boot and writes through on every mutation. `MemorySync` is |
| 86 | +*only* the optional remote mirror. |
| 87 | + |
| 88 | +- **`Hydrate`** — pulls the agent's accumulated content. Called by the |
| 89 | + Manager **only on a cold start** (empty local directory) to seed a fresh |
| 90 | + copy. |
| 91 | +- **`Push`** — mirrors each local write. **Best-effort**: the local write is |
| 92 | + the truth, so a push failure is logged and the agent keeps working. |
| 93 | +- **Standalone:** nil. Memory is purely local and durable across restarts |
| 94 | + (mount a persistent volume to survive container remounts). |
| 95 | + |
| 96 | +### Reconciliation on Restore |
| 97 | + |
| 98 | +`Restore(ctx, declared)` is called on every build with the memory files |
| 99 | +declared by the workflow. Content precedence is **local → cold-start mirror → |
| 100 | +declared seed**: |
| 101 | + |
| 102 | +1. An existing **local** copy wins — this preserves the agent's accumulated |
| 103 | + edits across redeploys. |
| 104 | +2. Otherwise, on a cold start with a mirror, the **hydrated** content seeds |
| 105 | + the file. |
| 106 | +3. Otherwise the workflow's declared `MemoryFile.Content` is used. |
| 107 | + |
| 108 | +So **`MemoryFile.Content` is initial content only — it never overwrites an |
| 109 | +existing file.** Declared *metadata* (label, description, size cap) is always |
| 110 | +authoritative; only content is preserved. |
| 111 | + |
| 112 | +> **Durability boundary:** with `Push` but no reverse path, the backend holds |
| 113 | +> only what the engine has pushed; runtime edits are durable on the local |
| 114 | +> volume. There is no backend → device or device → device reconciliation yet. |
| 115 | +> Adding a push-back path is additive and doesn't disturb this design. |
| 116 | +
|
| 117 | +## Wiring (cmd/engine/main.go) |
| 118 | + |
| 119 | +``` |
| 120 | +FH_BACKEND_URL set? |
| 121 | +├─ yes → backend.Client satisfies LlmClient (fallback), Retriever, |
| 122 | +│ Supervisor, and MemorySync. |
| 123 | +└─ no → LlmClient: local providers only |
| 124 | + Retriever: nil (retrieval nodes fail the build) |
| 125 | + Supervisor: nil (no register/heartbeat) |
| 126 | + MemorySync: nil (local-only memory) |
| 127 | +``` |
| 128 | + |
| 129 | +## Adding an adapter |
| 130 | + |
| 131 | +Group by **shared dependency**, not by port: |
| 132 | + |
| 133 | +- An adapter that talks to the fh-backend belongs in `engine/backend` (it |
| 134 | + shares the one HTTP client). |
| 135 | +- A heavy standalone implementation (its own driver, ingestion, etc.) gets |
| 136 | + its **own package** — e.g. a pgvector `Retriever` under `engine/rag`. |
| 137 | + Don't bundle it with trivial seams. |
| 138 | +- Edit `port.go` only to add or change a seam; never widen a port with a |
| 139 | + method a single adapter needs but the engine core doesn't call. |
0 commit comments