From c051f7614f6c106864cf69db78ae0e88281b1baa Mon Sep 17 00:00:00 2001 From: Adrian Cockcroft Date: Tue, 11 Aug 2026 19:41:02 -0700 Subject: [PATCH] docs: propose host adapters as a published extension point (ADR-0028/0029/0030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0029 asks for one thing: publish the seam that already exists. ADR-0016 specified every contract a host adapter needs, ADR-0017 proved them by using them, and ADR-0018 generalized execution behind them — sync and host pick already drive OpenCode through the generic runLifecycle. What is left is a last mile: adapter selection is a named import, and status hand-rolls a per-host block importing eight functions from lib/opencode.mjs. Both in-tree changes delete host-specific code rather than adding it. The alternative considered and rejected was absorbing each new host in-tree, as ADR-0017 did for OpenCode. It works, and it is why the contracts exist — but it makes every host a permanent obligation of whoever maintains this repository, including hosts they may not run and cannot verify. The first request for a fourth host is the right moment to decide that once rather than four times. Constraints that make the surface safe to publish rather than merely convenient: explicit kit.json registration, never naming-convention discovery (an unrelated npm install must not get third-party code executed inside ak); disclosure rather than a sandbox claim, since in-process adapters cannot be sandboxed; capability caps on canBePrimary, aqeProvider, and commandStatusline, matching the shape OpenCode already occupies; fail-closed per adapter, so a broken third-party adapter cannot brick ak status while built-ins keep throwing at construction; and contract: 1 with an explicit statement that the surface is unstable while the package is alpha. ADR-0030 is the conformance evidence. Hermes Agent breaks five assumptions the built-in hosts share — YAML config, no npm package, plain-text output, no interceptable permission event, no ruflo backend flag — and carrying it needed exactly one widening (a plain-text summary capture alongside the JSONL one) and one guard (npmRoot on an absent npmPackage). It ships as an externally maintained adapter, not vendored here. ADR-0028 is independent of both: the registry knows one local provider, ollama, while a local model is normally an OpenAI-compatible loopback endpoint, frequently under a user-chosen name no vendor enumeration can cover. docs/HOST-ADAPTER-EXTENSION-PROPOSAL.md is the companion product proposal, following the shape of PR #112. All three ADRs are Proposed. No implementation is authorized or claimed. --- docs/HOST-ADAPTER-EXTENSION-PROPOSAL.md | 233 ++++++++++++++++++ .../0028-local-openai-compatible-providers.md | 139 +++++++++++ docs/adr/0029-host-adapter-extension-point.md | 216 ++++++++++++++++ docs/adr/0030-hermes-reference-adapter.md | 195 +++++++++++++++ docs/adr/README.md | 39 +++ 5 files changed, 822 insertions(+) create mode 100644 docs/HOST-ADAPTER-EXTENSION-PROPOSAL.md create mode 100644 docs/adr/0028-local-openai-compatible-providers.md create mode 100644 docs/adr/0029-host-adapter-extension-point.md create mode 100644 docs/adr/0030-hermes-reference-adapter.md diff --git a/docs/HOST-ADAPTER-EXTENSION-PROPOSAL.md b/docs/HOST-ADAPTER-EXTENSION-PROPOSAL.md new file mode 100644 index 0000000..8308475 --- /dev/null +++ b/docs/HOST-ADAPTER-EXTENSION-PROPOSAL.md @@ -0,0 +1,233 @@ +# Host adapters as a published extension point + +**Companion proposal to [ADR-0029](adr/0029-host-adapter-extension-point.md), with +[ADR-0030](adr/0030-hermes-reference-adapter.md) as its first consumer and +[ADR-0028](adr/0028-local-openai-compatible-providers.md) as an independent prerequisite.** + +Status: **Proposed.** No implementation is authorized or claimed by this document. + +--- + +## 1. The request behind the proposal + +Someone wants agentic-kit to manage a fourth agent CLI — [Hermes +Agent](https://github.com/NousResearch/hermes-agent) — because it is the host that most naturally +drives local models, and local-model work is where they operate. + +The obvious way to grant that request is the way OpenCode was granted: an ADR, an owner module, and +edits across nine files. [ADR-0017](adr/0017-opencode-host.md) is an excellent record of what that +costs. Its own references section lists fourteen source files and eight test suites for **one** +host. + +The obvious way is also the wrong way here, for a reason that has nothing to do with hermes: it +makes every host a permanent obligation of whoever maintains agentic-kit. A fourth host means the +maintainer now owns compatibility with a CLI they may not run, cannot easily verify, and did not +choose. That is a poor trade for the maintainer and a fragile arrangement for the requester, whose +feature survives only as long as someone else's patience. + +This proposal asks for something different, and smaller: **publish the seam that already exists.** + +--- + +## 2. The seam already exists + +This is not a request to build an extension mechanism. It is a request to make one reachable. + +Every contract a host adapter would need is already specified **and already enforced** in this +repository: + +| What an adapter must satisfy | Where it is already defined | +|---|---| +| Host descriptor + capabilities | `validateHostAdapter` — `src/lib/adapters/registries.mjs` | +| Cross-axis invariants | `validateRegistries`, run at construction | +| Configuration lifecycle | `validateLifecycleAdapter` — `detect`/`plan`/`apply`/`verify`/`undo` | +| Ownership and safe teardown | `ownership`, `mayUndo`, `undoOwnedValues` | +| Worker execution | `validateExecutionAdapter` (8 methods), `validateWorkerResult` | +| Shared subprocess base | `createSubprocessExecutionAdapter` | +| Normalized, provenance-bearing facts | `normalizedFacts` — `schemaVersion: 1` | +| Guidance rows | `registry(customBlocks)` — **already user-extensible via kit.json** | + +And adoption is real. `OPENCODE_LIFECYCLE_ADAPTER` implements the full five-verb contract, and both +`src/commands/sync.mjs` and `src/commands/x/host.mjs` already drive it through the generic +`runLifecycle` — not through opencode-specific dispatch. + +ADR-0016 did the hard part. ADR-0017 proved it by using it. ADR-0018 generalized execution behind +it. What remains is the last mile. + +### 2.1 The last mile, precisely + +Two things are still hardcoded: + +**Adapter selection is a named import.** `runLifecycle({ adapter: OPENCODE_LIFECYCLE_ADAPTER, … })` +— the driver is generic, the lookup is not. + +**`status` hand-rolls a per-host block.** `src/commands/status.mjs` imports eight functions directly +from `src/lib/opencode.mjs` to render opencode's rows, even though `detect` already returns facts in +the versioned `normalizedFacts` schema that a generic renderer could consume. + +That is the whole gap. Closing it deletes host-specific code from `status.mjs` rather than adding +any — which is why both changes are worth making even if no external adapter is ever registered. + +--- + +## 3. What is actually being proposed + +An out-of-tree adapter is one module exporting one manifest, validated by the validators above: + +```js +export const akHostAdapter = { + contract: 1, + host, projections: [], observability: [], + lifecycle, // detect / plan / apply / verify / undo + execution, // required iff canRouteActivities + guidance: [], // existing customBlocks row shape +}; +``` + +Registered explicitly, in the user's own config: + +```json +{ "hostAdapters": [ { "id": "hermes", "module": "@example/ak-host-hermes" } ] } +``` + +Four constraints make this safe to publish rather than merely convenient: + +1. **Explicit registration, never a naming convention.** Scanning the global npm tree for + `ak-host-*` would make an unrelated `npm install` sufficient to get third-party code executed + inside ak on the next `ak status`. That is the fail-open pattern + [ADR-0023](adr/0023-fail-closed-operations-and-explicit-degradation.md) exists to prevent. + +2. **Disclosure, not a sandbox claim.** ak cannot sandbox an in-process module and the ADR does not + pretend it can. The trust manifest gains a `third-party-adapter` kind naming the package, its + resolved path, and its version, printed before any mutation — the same pre-disclosure surface + ADR-0023 already requires elsewhere. + +3. **Capability caps.** External adapters may not claim `canBePrimary`, an `aqeProvider`, or + `commandStatusline`. Not distrust — those three carry first-party obligations ak cannot discharge + for code it does not ship. The cap is exactly the shape OpenCode already occupies, so it is a + tested configuration rather than new policy. + +4. **Fail-closed per adapter.** A broken third-party adapter is reported and skipped; it must never + brick `ak status` for the hosts that work. First-party registries keep throwing at construction, + because a broken built-in *is* a build error. + +Plus one thing said out loud rather than discovered later: **while the package is `4.0.0-alpha.*`, +this surface is unstable and may change between alphas.** Publishing an extension point acquires an +obligation, and the alpha statement is what keeps it bounded until there is evidence about what +actually needs to change. + +--- + +## 4. Why hermes is a good first consumer + +A contract never satisfied by code its authors did not write is a guess. Hermes is a useful test +precisely because it is awkward — it breaks five assumptions the three built-in hosts share: + +| Built-in hosts assume | Hermes | +|---|---| +| JSON or TOML config | **YAML**, relocatable via `HERMES_HOME` and profiles | +| npm-installable | pip / uv / vendor installer — **no npm package** | +| A structured JSON or JSONL run mode | plain text on stdout | +| An interceptable permission event | none — headless mode auto-approves by contract | +| A ruflo `ENABLE_*` backend flag | none; ruflo's backend list is fixed | + +The conformance result is the interesting part. Carrying hermes needed **one widening and one +guard**: + +- a plain-text summary capture alongside `createJsonlSummaryCapture` in the shared subprocess base; +- a guard on `npmRoot(host.install.npmPackage)` in `src/lib/footprint/install.mjs`, since hermes is + the first host with no npm package. + +Everything else fit: the capability caps, the lifecycle verbs, ownership receipts, guidance rows, +`normalizedFacts`, and ADR-0018's handoff surface. That is a good result for a contract designed +without hermes in view, and it is the kind of evidence that only a genuine outside consumer +produces. + +Two findings are worth reading even if the extension point is declined, because they are the sort of +thing that becomes a bug report later: + +- **`hermes mcp add` is not safely idempotent.** Overwrite prompts with `default=False`; `remove` + prompts with `default=True`; `_confirm` returns its default on `EOFError`, which is what a non-TTY + `ak sync` supplies. A bare re-`add` prints "Cancelled." and **exits zero** — a silent no-op that + reads as convergence. Hence remove-then-add. +- **`hermes -z` auto-approves everything.** It sets `HERMES_YOLO_MODE=1` by its own headless + contract, so unlike OpenCode there is no permission event to intercept and no `permission_required` + result to return. ADR-0030 discloses this at enable time rather than letting a hermes worker + appear to carry a guarantee it does not have. + +--- + +## 5. Who maintains what + +| agentic-kit | The adapter author | +|---|---| +| The contract, its version, and its tests | The adapter and its tests | +| Registry admission and validation | `detect`/`plan`/`apply`/`verify`/`undo` | +| Driving the lifecycle | Its host's config writing — backup-first, merge-not-clobber | +| Command wiring (setup, sync, status, uninstall, `host pick`) | An execution adapter if routable | +| Trust disclosure before mutation | Honest capability declarations | +| Calling `undo` on teardown | Ownership receipts | +| A fixture adapter proving the contract in CI | Its host's correctness and vendor churn | + +The hermes adapter is offered on those terms: authored and maintained outside this repository, by +the person who wants it, with agentic-kit owning only the contract it conforms to. + +The package also stays **zero-runtime-dependency**. An adapter is something the *user* installs and +registers; it is never a dependency of `@pacphi/agentic-kit`. That property was load-bearing in the +hermes design too — it is why ADR-0030 drives `hermes config path` / `mcp add` instead of taking a +YAML library and writing hermes's config directly. + +--- + +## 6. Scope + +**In scope.** Registry-driven adapter selection; a `status` renderer over `normalizedFacts`; a +loader with explicit registration, contract versioning, capability caps, per-adapter fail-closed +admission, and trust disclosure; a fixture adapter in `tests/`; the `npmPackage` guard; a plain-text +subprocess capture. + +**Out of scope.** A subprocess/RPC adapter protocol — genuinely sandboxable and language-agnostic, +and deferred rather than dismissed, because re-specifying two lifecycles as a wire protocol is a far +larger commitment than one requesting consumer justifies. Extension points for providers, +observability sources, or commands: providers are already declarable as data, and command extension +has no requesting use case. Any semver-stability promise, which belongs to a later decision made +with evidence. + +**Explicitly not requested.** That hermes be vendored into this repository, that agentic-kit take on +hermes's correctness or NousResearch's release cadence, or that any built-in host's behavior change. + +--- + +## 7. Sequencing + +Each step is independently valuable and independently reversible. + +1. **[#130](https://github.com/pacphi/agentic-kit/pull/130)** — an unrelated bug fix, already filed: + `globalRoot()` misses Homebrew's kegged layout, failing six `provider-cli` tests on any + Homebrew-node macOS checkout. Mentioned only because it currently blocks any new test evidence on + such a machine. +2. **ADR-0028** — the local OpenAI-compatible provider. Independent of everything here, and useful + to the hosts already shipped. +3. **ADR-0029** — this proposal, as a decision. Nothing is built until it is accepted. +4. **The two in-tree refactors**, which stand on their own merits: registry-driven selection, and + `status` over `normalizedFacts`. +5. **ADR-0030** — the hermes adapter, published and maintained externally, returning conformance + evidence. + +Declining at step 3 costs nothing already spent, and steps 1 and 2 remain worth having. + +--- + +## 8. The honest case against + +- **A published contract acquires consumers, and consumers constrain refactors.** This is the real + cost, and §3's alpha-instability statement bounds it rather than eliminating it. +- **One requesting consumer is thin evidence for a general mechanism.** A fair objection. The + counter is that the mechanism is mostly already built and partly already adopted, so the marginal + cost is a loader and two refactors that delete code — not a speculative framework. +- **In-process third-party code cannot be sandboxed.** True, and stated rather than mitigated. The + disclosure surface is the honest version of the guarantee, in the same spirit as ADR-0018's + trust-boundary section. +- **The status quo works.** It does — for three hosts chosen by one maintainer. The question this + proposal raises is what happens at the fourth request, and whether it is better answered once than + four times. diff --git a/docs/adr/0028-local-openai-compatible-providers.md b/docs/adr/0028-local-openai-compatible-providers.md new file mode 100644 index 0000000..5126281 --- /dev/null +++ b/docs/adr/0028-local-openai-compatible-providers.md @@ -0,0 +1,139 @@ +# ADR-0028 — One generic local OpenAI-compatible provider, not a vendor enumeration + +- **Status:** Proposed +- **Date:** 2026-08-11 +- **Deciders:** agentic-kit maintainers +- **Related:** [ADR-0011](0011-local-model-provenance-zero-cost-and-transcript-fidelity.md), + [ADR-0016](0016-capability-driven-integration-adapters.md), + [ADR-0021](0021-inference-provider-provenance.md) + +## Context + +[ADR-0016](0016-capability-driven-integration-adapters.md) separates inference **providers** from +execution **hosts**, and [ADR-0011](0011-local-model-provenance-zero-cost-and-transcript-fidelity.md) +governs what a local provider may claim. The provider registry today declares exactly **one** local +provider — `ollama` — and `BUILTIN_BINDINGS` carries exactly two local bindings, +`ollama-via-claude` and `ollama-via-codex` (`src/lib/adapters/registries.mjs`, +`src/lib/adapters/bindings.mjs`). + +Ollama is not the only way a local model is served, and on real machines it is frequently not the +one in use. A local inference server is normally reached as an **OpenAI-compatible HTTP endpoint on +loopback**: MLX/`mlx_lm.server`, LM Studio, `llama.cpp`'s server, and vLLM all present that shape. +Nothing in ak can currently name such an endpoint as a provider, so a machine running one has its +local inference either invisible or misfiled. + +This is observable on the reference machine. `~/.hermes/config.yaml` declares: + +```yaml +provider: mlxlocal +providers: + mlxlocal: + api: http://127.0.0.1:8080/v1 + api_mode: openai + default_model: mlx-community--Qwen3-Coder-Next-4bit +``` + +Two facts follow. First, the endpoint is a plain OpenAI-compatible loopback URL — the generic shape, +not a vendor-specific protocol. Second, **the provider name is user-chosen** (`mlxlocal`). No +enumeration of vendor ids can cover that case; a registry that lists `mlx`, `lmstudio`, `llamacpp`, +and `vllm` still has no row for `mlxlocal`. + +The binding machinery already accommodates this. `validateEndpoint` accepts loopback `http://` +while rejecting remote `http://`, embedded credentials, fragments, and secret-bearing query +parameters (`src/lib/adapters/config.mjs`). `http://127.0.0.1:8080/v1` is a legal binding endpoint +**today**; only the provider row is missing. + +## Decision + +### 1. Add one generic provider row: `local-openai` + +A single provider represents "an OpenAI-compatible model server the user runs locally", regardless +of which program serves it: + +- `billing: 'local'`, `credentials: { kind: 'none' }`, `capabilities.pricing: 'zero'` — required by + the registry's own construction invariants for a local provider (`validateRegistries`), and + correct: a loopback server bills nothing. A server that wants a placeholder token (`api_key: + local` above) does not make the credential *required*, so `kind: 'none'` remains accurate. +- `transports: ['openai-compatible']` — the only transport the row may claim. Anthropic-compatible + and native shells are Ollama's, established separately. +- `capabilities.modelDiscovery: false`, `runtimeDiscovery: false`, `quota: false`, + `cacheAccounting: 'unknown'`. A generic endpoint exposes no catalogue ak may rely on. Claiming + `/v1/models` discovery would assert a uniformity across MLX, LM Studio, llama.cpp, and vLLM that + this ADR has not measured. +- `observability: []`. Ollama keeps `ollama-catalog` / `ollama-runtime`; the generic row gets + neither, because it has no daemon API ak has verified. + +`ollama` is unchanged. It keeps its richer transports and its two observability sources precisely +because those rest on a specific, known daemon. + +### 2. The endpoint carries the identity; the provider row does not + +Which program serves a `local-openai` binding is recorded as the **binding's** endpoint and model, +not as provider identity. A user running MLX on `:8080` and LM Studio on `:1234` has two bindings +against one provider — the same relation ADR-0011 already names for +`ollama-via-claude` / `ollama-via-codex`, one level more general. + +Consistent with [ADR-0021](0021-inference-provider-provenance.md), such a binding establishes +**configured** provenance and nothing stronger. The endpoint is user-declared, so it may not be +displayed as observed, and it does not upgrade model, token, cache, or digest claims. The `$0` +claim is the one exception and is a property of the billing type, not of evidence about the run. + +### 3. No built-in bindings for the generic provider + +`BUILTIN_BINDINGS` gains nothing here. Ollama's two rows are justified by a fixed, well-known +default port; a generic local endpoint has no default ak may presume. Bindings are declared by the +user in `kit.json` and validated by the existing `assertValidBinding` path. + +### 4. Replace the derived capability block with per-entry data + +`providerEntries` currently derives capabilities from identity comparisons inside a `.map` +(`modelDiscovery: id === 'ollama'`, `pricing: id === 'ollama' ? 'zero' : …`). That construction +does not survive a second local provider: `local-openai` needs `pricing: 'zero'` without +`modelDiscovery`, which the `id === 'ollama'` coupling cannot express. Provider entries become +explicit records carrying their own capability block, matching how `hostEntries` is already +written. + +## Consequences + +- A machine serving models from MLX, LM Studio, llama.cpp, vLLM, or anything else speaking + OpenAI-compatible HTTP on loopback can be described to ak without a new ADR per vendor, and + without inventing a provider id the user did not choose. +- Every host that can be pointed at an OpenAI-compatible base URL gains a nameable local provider — + this is not hermes-specific work, and it lands independently of + [ADR-0029](0029-host-adapter-extension-point.md) and + [ADR-0030](0030-hermes-reference-adapter.md). +- The generic row deliberately supports **less** than `ollama`: no catalogue, no runtime probe, no + digest. Surfaces that show local-model detail for Ollama will show less for `local-openai`, and + that gap is the honest reading of the evidence, not a defect to paper over. +- `local-openai` is not an AQE provider type and is not projected as one. AQE's own local routing + (`ollama`, `onnx`) is a separate axis and is untouched. + +## Alternatives considered + +- **Named rows per runtime (`mlx`, `lmstudio`, `llamacpp`, `vllm`).** Rejected for this revision on + two grounds. It cannot cover a user-named provider such as the observed `mlxlocal`, so the + generic row is required regardless and the named rows would be additive decoration. And each row + would assert transport and discovery facts for a server this repository has not measured — + precisely the derivation-without-measurement that + [docs/LOCAL-MODEL-VALIDATION.md](../LOCAL-MODEL-VALIDATION.md) exists to correct. Named rows + remain available later, gated on an evidence pass of the same kind, and would then be able to + claim real `/v1/models` discovery instead of guessing at it. +- **Extend `ollama` to mean "any local server".** Rejected: it would make an established provider + id lie about which daemon is answering, and `ollama-catalog` / `ollama-runtime` would be attached + to endpoints that serve neither. +- **Infer the runtime by probing the endpoint.** Rejected as a default: ak would be spawning + network probes during status collection to manufacture an identity claim that ADR-0021 would then + have to grade as inferred anyway. The user naming their own binding is cheaper and more honest. + +## References + +- `src/lib/adapters/registries.mjs` (`providerEntries`, `validateProviderAdapter`, + `validateRegistries` local-billing invariants), `src/lib/adapters/bindings.mjs` + (`BUILTIN_BINDINGS`, `assertValidBinding`), `src/lib/adapters/config.mjs` (`validateEndpoint` + loopback rule). +- ADR-0011 (local-model provenance, `$0`, transcript fidelity), ADR-0016 (provider/binding + separation), ADR-0021 (provenance is carried, never upgraded). +- Observed local configuration: `~/.hermes/config.yaml` on the reference machine + (`api: http://127.0.0.1:8080/v1`, `api_mode: openai`). +- Tests to extend: `tests/kit/{providers,integration-config}.test.mjs` (registry invariants for a + second local provider, binding validation against a loopback OpenAI-compatible endpoint). diff --git a/docs/adr/0029-host-adapter-extension-point.md b/docs/adr/0029-host-adapter-extension-point.md new file mode 100644 index 0000000..d7d53bb --- /dev/null +++ b/docs/adr/0029-host-adapter-extension-point.md @@ -0,0 +1,216 @@ +# ADR-0029 — Host adapters as a published extension point + +- **Status:** Proposed +- **Date:** 2026-08-11 +- **Deciders:** agentic-kit maintainers +- **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md), + [ADR-0017](0017-opencode-host.md), [ADR-0018](0018-generalized-host-worker-execution.md), + [ADR-0023](0023-fail-closed-operations-and-explicit-degradation.md) +- **Product proposal:** + [Host adapters as a published extension point](../HOST-ADAPTER-EXTENSION-PROPOSAL.md) +- **First consumer:** [ADR-0030](0030-hermes-reference-adapter.md) + +## Context + +ADR-0016 replaced hardcoded host lists with a capability registry; ADR-0017 proved the shape by +adding a third host; ADR-0018 generalized execution behind a host-neutral lifecycle. The result is +that **the contracts an agent CLI must satisfy are already written down and already enforced**: + +| Contract | Validator | Location | +|---|---|---| +| Host descriptor | `validateHostAdapter` | `src/lib/adapters/registries.mjs` | +| Projection / observability | `validateProjectionAdapter`, `validateObservabilityAdapter` | same | +| Cross-axis invariants | `validateRegistries` | same, run at construction | +| Configuration lifecycle | `validateLifecycleAdapter` (`detect`/`plan`/`apply`/`verify`/`undo`) | `src/lib/adapters/lifecycle.mjs` | +| Ownership + teardown | `ownership`, `mayUndo`, `undoOwnedValues` | `lifecycle.mjs`, `ownership.mjs` | +| Worker execution | `validateExecutionAdapter` (8 methods), `validateWorkerResult` | `src/lib/execution/schema.mjs` | +| Normalized facts | `normalizedFacts` (`schemaVersion: 1`, provenance-bearing) | `src/lib/adapters/facts.mjs` | +| Guidance rows | `registry(customBlocks)` — already user-extensible via kit.json | `src/lib/blocks.mjs` | + +Adoption is real, not aspirational. `OPENCODE_LIFECYCLE_ADAPTER` implements the full five-verb +contract, and both `src/commands/sync.mjs` and `src/commands/x/host.mjs` already drive it through +the generic `runLifecycle` rather than through opencode-specific dispatch. A reusable +`createSubprocessExecutionAdapter` already factors what Claude and Codex share. + +What is **not** generic is the last mile. Adapter *selection* is a module import of a named constant +(`runLifecycle({ adapter: OPENCODE_LIFECYCLE_ADAPTER, … })`), and `src/commands/status.mjs` +hand-rolls a per-host block that imports eight functions directly from `src/lib/opencode.mjs`. So +every new host still costs edits across `setup`, `sync`, `status`, `uninstall`, `x/host`, +`providers`, `versions`, `nudge`, and the footprint collectors — even though the contracts those +edits satisfy are already specified. + +That last mile is what makes a fourth host an ask rather than a plug-in. A host maintained by +someone who does not maintain agentic-kit currently has two options, both bad: land in-tree and +become the maintainer's permanent obligation for a CLI they may not run, or fork and drift on every +upstream refactor. + +## Decision + +### 1. Publish the adapter contract; do not absorb hosts one at a time + +An **out-of-tree host adapter** is a node module exporting a single manifest, validated by the +validators that already exist: + +```js +export const akHostAdapter = { + contract: 1, + host, // → validateHostAdapter + projections: [], // → validateProjectionAdapter + observability: [], // → validateObservabilityAdapter + lifecycle, // → validateLifecycleAdapter + execution, // → validateExecutionAdapter (required iff canRouteActivities) + guidance: [], // → block registry rows, the existing customBlocks shape +}; +``` + +The contract is not new surface area invented for this ADR. It is the set of interfaces ADR-0016 and +ADR-0018 already defined, made **reachable** — exported from a stable entrypoint and admitted at +runtime rather than only at module-construction time. + +### 2. Discovery is explicit registration, never a naming convention + +Adapters are named in `kit.json`: + +```json +{ "hostAdapters": [ { "id": "hermes", "module": "@example/ak-host-hermes" } ] } +``` + +Scanning `npm root -g` for an `ak-host-*` prefix is **rejected**. It would make an unrelated +`npm install` sufficient to get third-party code executed inside ak on the next `ak status` — the +precise fail-open pattern ADR-0023 exists to prevent. Registration is a deliberate act, recorded in +the user's own config, and removable by editing one array. + +### 3. Third-party code runs with ak's privileges; ak discloses rather than pretends + +ak cannot sandbox an in-process node module, and this ADR does not claim otherwise. Instead: + +- Host trust manifests gain a `third-party-adapter` change kind. Before any mutation, the manifest + names the package, its **resolved path**, and its version — the same pre-disclosure surface + ADR-0023 already requires for approvals and MCP registrations. +- ADR-0018's trust-boundary contract is restated at registration: an adapter is code you are + choosing to run with your user privileges, exactly like the repositories `ak run` operates in. + +A subprocess/RPC adapter protocol would be genuinely sandboxable and language-agnostic, and is +**deferred, not dismissed** — re-specifying the eight-method execution lifecycle and the five-verb +configuration lifecycle as a wire protocol is a much larger commitment than this ADR should make +before a second adapter exists to generalize from. + +### 4. External adapters are capability-capped + +The loader refuses an adapter that declares `canBePrimary: true`, a non-null `aqeProvider`, or +`commandStatusline: true`, and never auto-seeds its routes. + +This is not distrust; it is that those three surfaces carry **first-party obligations** ak cannot +discharge for code it does not ship — primary-host mirroring (ADR-0006), AQE provider projection, +and statusline rendering all require ak-side behavior keyed to a specific host. The cap is exactly +the shape OpenCode already occupies, so it is a tested configuration rather than new policy. An +external host may be `canDriveSession` and `canRouteActivities`; those are contract-satisfiable. + +### 5. Loading is fail-closed per adapter, not per process + +First-party registries throw at construction, deliberately: a broken built-in is a build error. +A broken **third-party** adapter must not be able to brick `ak status` for the hosts that work. + +Each adapter is validated in isolation. A failure — bad contract version, failed validator, module +that will not import — is reported with the package name, the failing path, and the validator's own +error, and that adapter alone is skipped. Every other subsystem continues and says so, per +ADR-0023's explicit-degradation rule. A refused adapter never leaves partial wiring behind, because +nothing is applied before admission. + +### 6. `contract: 1`, and no stability promise while the package is alpha + +The manifest carries an integer contract version. A mismatch is refused with a message naming both +the adapter's version and ak's, rather than being coerced. + +Stated plainly, because publishing an extension point implies an obligation that is easy to acquire +by accident: **while `@pacphi/agentic-kit` is `4.0.0-alpha.*`, this surface is unstable and may +change between alphas.** Adapter authors track alphas or pin. A semver-stable commitment is a +separate, later decision, made when there is evidence about what actually needs to change. + +### 7. What each side owes + +| ak owes | The adapter owes | +|---|---| +| Registry admission + validation | `detect`/`plan`/`apply`/`verify`/`undo` | +| Driving the lifecycle (`runLifecycle`) | Its host's own config writing, backup-first and merge-not-clobber | +| Command wiring: setup, sync, status, uninstall, `host pick` | An execution adapter if it claims `canRouteActivities` | +| Guidance reconciliation via existing block rows | Guidance block templates in its host's idiom | +| Trust disclosure before mutation | Honest capability declarations | +| Calling `undo` on teardown | Ownership receipts via `ownership`/`mayUndo` | +| The contract, its version, and its tests | Its own tests, and its host's correctness | + +### 8. The in-tree work this requires + +Three changes, each independently useful even if no external adapter ever ships: + +- **Adapter selection becomes registry-driven.** `sync` and `x/host` resolve the lifecycle adapter + from the registry by host id instead of importing a named constant. The driver is already + generic; only the lookup changes. +- **`status` renders hosts from `normalizedFacts`.** A lifecycle adapter's `detect` already returns + facts in a versioned, provenance-bearing schema. Rendering from that schema replaces the + hand-wired per-host block, which is the only reason `status.mjs` imports host internals at all. +- **A fixture adapter in `tests/`** exercises admission, capability caps, contract-version refusal, + per-adapter isolation, and teardown — so the extension point is proven in-tree, with no external + package required for CI. + +`install.npmPackage` must also become genuinely optional: `src/lib/footprint/install.mjs` calls +`npmRoot(host.install.npmPackage)` unguarded, and a host installed by pip, uv, or a vendor script +has none. + +## Consequences + +- The maintenance split inverts. agentic-kit owns a small versioned contract, a loader, and a + fixture adapter. Host-specific correctness — a config format ak never parses, a CLI ak never + spawns, a vendor's release cadence — belongs to whoever wants that host, and their breakage is + their breakage. +- ADR-0017's per-host cost stops being the template. A fourth, fifth, or sixth host does not + require a fourth, fifth, or sixth ADR in this repository. +- The two in-tree refactors delete host-specific code rather than adding it: `status.mjs` stops + importing `lib/opencode.mjs` internals, and adapter selection stops being a hardcoded import. +- The package stays **zero-runtime-dependency**. An adapter is a package the *user* installs and + registers; it is not a dependency of `@pacphi/agentic-kit`. +- The honest cost: a published contract acquires consumers, and consumers constrain refactors. §6's + alpha-instability statement is what keeps that cost bounded until it is worth paying. +- A user who registers a hostile adapter has run hostile code with their own privileges. §3 + discloses this; it does not prevent it, and no in-process design could. + +## Alternatives considered + +- **Absorb each host in-tree, as ADR-0017 did for OpenCode.** Rejected as the general answer. It + works, and it is why the contracts exist — but it makes every host a permanent obligation for a + solo maintainer, including hosts they do not run and cannot verify. The first request for a + fourth host is the right moment to decide this once rather than four times. +- **Naming-convention discovery from the global npm tree.** Rejected under §2. +- **A subprocess/RPC adapter protocol.** Deferred under §3. +- **Leave external hosts to forks.** Rejected: it is the worst outcome for both sides. The fork + drifts on every refactor, and agentic-kit gets no conformance evidence about whether its own + abstractions actually hold. +- **A plugin API broader than hosts (providers, observability sources, commands).** Rejected as + scope: hosts are where the demand and the proven contracts are. Providers are already declarable + as data, and command extension has no requesting use case. + +## Required evidence + +This ADR is Proposed; no implementation is authorized or claimed. Promotion to Accepted requires: + +- The fixture adapter passing admission, cap-refusal (`canBePrimary`, `aqeProvider`, + `commandStatusline`), contract-version refusal, and per-adapter isolation — one bad adapter + leaves the rest of `ak status` intact and reported. +- `sync`, `status`, `host pick`, and `uninstall` driving a registered adapter with no host-specific + imports, and OpenCode passing unchanged through the registry-driven path. +- A trust manifest that names the package, resolved path, and version before the first mutation. +- Teardown proof: `ak host off` calls `undo`, an adapter whose `undo` fails retains its markers and + reports honestly rather than claiming a clean removal. +- One real external adapter as conformance evidence — [ADR-0030](0030-hermes-reference-adapter.md). + +## References + +- Existing contracts: `src/lib/adapters/{registries,lifecycle,ownership,facts,schema}.mjs`, + `src/lib/execution/{schema,subprocess,adapters}.mjs`, `src/lib/blocks.mjs` (`registry`, + `customBlocks`). +- Existing adoption: `OPENCODE_LIFECYCLE_ADAPTER` (`src/lib/opencode.mjs`), driven via + `runLifecycle` from `src/commands/sync.mjs` and `src/commands/x/host.mjs`. +- The remaining last mile: the per-host block in `src/commands/status.mjs`, and + `npmRoot(host.install.npmPackage)` in `src/lib/footprint/install.mjs`. +- ADR-0016 (the registry this publishes), ADR-0017 (the per-host cost this replaces), + ADR-0018 (execution lifecycle and trust boundary), ADR-0023 (fail-closed, pre-disclosure). diff --git a/docs/adr/0030-hermes-reference-adapter.md b/docs/adr/0030-hermes-reference-adapter.md new file mode 100644 index 0000000..b43c414 --- /dev/null +++ b/docs/adr/0030-hermes-reference-adapter.md @@ -0,0 +1,195 @@ +# ADR-0030 — Hermes Agent as the first out-of-tree host adapter + +- **Status:** Proposed +- **Date:** 2026-08-11 +- **Deciders:** agentic-kit maintainers +- **Related:** [ADR-0028](0028-local-openai-compatible-providers.md), + [ADR-0029](0029-host-adapter-extension-point.md), + [ADR-0017](0017-opencode-host.md), [ADR-0018](0018-generalized-host-worker-execution.md) +- **Product proposal:** + [Host adapters as a published extension point](../HOST-ADAPTER-EXTENSION-PROPOSAL.md) + +## Context + +[ADR-0029](0029-host-adapter-extension-point.md) requires one real external adapter as conformance +evidence: a contract that has never been satisfied by code its authors did not write is a guess. + +**Hermes Agent** ([NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent)) is the +candidate, and it is a deliberately awkward one — which is the point. Every assumption ak's three +built-in hosts share, hermes breaks at least one of: + +| Assumption from claude / codex / opencode | Hermes | +|---|---| +| Config is JSON or TOML | **YAML**, relocatable via `HERMES_HOME` and profiles | +| Installed from npm | pip / uv / vendor installer — **no npm package** | +| A structured JSON or JSONL run mode | plain text on stdout | +| A permission event a runner can intercept | none — headless mode auto-approves by contract | +| A ruflo `ENABLE_*` backend flag exists | none; ruflo's backend list is fixed | + +If the ADR-0029 contract can carry hermes without amendment, it can carry most things. Where it +cannot, this ADR records the amendment as evidence rather than hiding it. + +Hermes also motivates [ADR-0028](0028-local-openai-compatible-providers.md): its provider layer +treats `ollama`, `vllm`, `llamacpp`, and `lmstudio` as aliases onto a generic OpenAI-compatible +`custom` provider (`hermes_cli/runtime_provider.py`), and arbitrary loopback endpoints are +declarable inline. It is the host a local-model operator is most likely to already be running. + +## Decision + +### 1. Hermes ships as an external adapter, maintained by its proposer + +The adapter is a separate package registered through ADR-0029's `kit.json hostAdapters` array. It +is **not** vendored into this repository, and agentic-kit does not take on hermes's correctness, +its release cadence, or its vendor's changes. The host-specific decisions — how its config is +wired, how its CLI is invoked, what its guidance says — live in that package's own documentation. + +What this ADR records is only what agentic-kit must know: the conformance result, and the in-tree +changes hermes proved were necessary. + +### 2. Declared capabilities + +`canDriveSession: true`, `canRouteActivities: true`, `canBePrimary: false`, `commandStatusline: +false`, `aqeProvider: null` — inside ADR-0029 §4's cap without needing an exemption. AQE has no +hermes provider type, and inventing one would manufacture provider identity that +[ADR-0021](0021-inference-provider-provenance.md) forbids. + +### 3. The adapter drives hermes's own CLI and never writes its YAML + +The lifecycle adapter locates the config with `hermes config path` (letting hermes resolve +`HERMES_HOME` and profiles rather than reimplementing that chain), wires MCP with +`hermes mcp add` / `hermes mcp remove`, and never touches `providers.*`, `model`, or +`default_provider` — those are the user's inference choices, which ADR-0021 has ak read, not author. + +This is ak's standing "write the host's own config, never a parallel config layer" rule in its +strongest form, and it is why **no YAML dependency enters this repository or the adapter**. + +Two findings from the source are recorded because they are non-obvious and would otherwise be +rediscovered as bugs: + +- **Idempotence requires remove-then-add.** `hermes mcp add` prompts to overwrite an existing name + with `default=False`; `hermes mcp remove` prompts with `default=True`; and `_confirm` returns its + default on `EOFError` — which is what a non-TTY `ak sync` supplies. A bare re-`add` therefore + prints "Cancelled." and **exits zero**, a silent no-op that would read as convergence. +- **Convergence must be read from the file, not the CLI.** `hermes mcp list` renders a human table + with no `--json`. The adapter reads the file `hermes config path` names, under a restricted + reader that **refuses rather than guesses** on YAML constructs outside its subset. It never + writes, so a refusal costs status fidelity and never user data. + +### 4. Execution: `hermes -z`, whose stdout is a framed final-assistant channel + +ADR-0018 §7 admits a dependency handoff only from a host's structured final assistant surface, +never from raw stdout. Hermes has no JSON stream, but its oneshot mode satisfies that requirement in +substance: `run_oneshot` disables logging, redirects both stdout and stderr to `devnull` for the +entire agent call tree, and writes the final response to the real stdout in a single write after the +run completes (`hermes_cli/oneshot.py`). Tool output, banners, and progress never reach the channel. + +The weaker guarantee is stated rather than hidden: with no framing, a truncated run is not +distinguishable from a short answer by inspection. Failed handoff extraction stays a bounded +`protocol_error`, and hermes's exit codes carry what ak relies on — `0` success, `1` agent failure +or no final response, `2` bad arguments or a failed/partial run with no text. + +`--usage-file` writes a JSON report (estimated cost, tokens, model, api_calls) **even when the run +fails**, giving per-worker usage with no transcript scraping. Per ADR-0021 this is the host's own +accounting: `configured`-grade evidence of what hermes believes it spent. `worker.maxTurns` is not +forwarded — hermes's `max_turns` is config-level with no oneshot flag, so the bound rides on the +runner timeout, as it already does for Codex. + +**This is the one place the ADR-0029 contract needed widening.** `createSubprocessExecutionAdapter` +supports only `createJsonlSummaryCapture`; a plain-text capture is required. It is a small addition +to a shared helper, and it is recorded here because it is exactly the kind of gap a first external +consumer exists to find. + +### 5. The approval posture is disclosed, because ak cannot constrain it + +`hermes -z` sets `HERMES_YOLO_MODE=1` and `HERMES_ACCEPT_HOOKS=1` before running, auto-approving +every shell and tool approval — with the stated rationale that a non-interactive prompt would hang +forever (`hermes_cli/oneshot.py`). + +This differs materially from OpenCode, where ADR-0018 §5 routes a permission request into a +deterministic `permission_required` result and refuses to answer on the user's behalf. **Hermes +oneshot has no permission event to intercept.** ak does not weaken hermes's posture — auto-approval +is hermes's own headless contract — but a hermes worker must not be presented as carrying a +guarantee it does not have. + +The adapter therefore declares auto-approval as an explicit `host-integration` trust change, printed +in the pre-mutation manifest, and ADR-0018's trust-boundary contract is restated at enable time +rather than assumed to carry over. This is also the first test of whether ADR-0029 §3's disclosure +surface is expressive enough for an adapter to describe a risk ak did not anticipate. + +### 6. Guidance reuses the existing project `agents` target + +Hermes reads `AGENTS.md` from the working directory — already +`{ name: 'agents', file: path.join(cwd, 'AGENTS.md') }` in `guidanceTargets`. No fourth target is +created. The adapter contributes an enablement-gated block row through the existing `customBlocks` +shape, so a template asserting live wiring never lands on an installed-but-disabled host. + +### 7. Detected, never installed or updated + +`install.bin: 'hermes'`, `externalInstallPolicy: 'detect-never-overwrite'`, and **no +`npmPackage`** — hermes is the first host to have none, which is what forces ADR-0029 §8's +`npmRoot(undefined)` guard. `ak setup` and `ak sync` never install or upgrade it. + +PATH detection alone is insufficient in practice: on the reference machine hermes lives in +`~/hermes-venv/bin/hermes` and a repo `.venv`, and is absent from PATH. The adapter accepts an +explicit binary path in its own configuration; an enabled host that cannot be resolved is reported +as not-installed, never silently skipped. + +### 8. Out of scope + +AQE provider routing, statusline, primary eligibility, auto-seeding, and `drivingHost` session +detection — all excluded by ADR-0029 §4 or by absence of an upstream surface. + +Also excluded: **a reverse MCP bridge.** `hermes mcp serve` exists, but publishes a messaging +bridge — `conversations_list`, `messages_read`, `messages_send`, `permissions_respond` +(`mcp_serve.py`) — not a task-delegation tool. There is no `mcp__hermes__hermes` analogue to Codex's +`codex mcp-server`. Registering it would grant Claude the ability to read and send messages on the +user's platforms under the guise of host integration: a far larger grant than the delegation it +superficially resembles. Delegation to hermes is a subprocess invocation, which is what `ak run` +performs anyway. + +## Consequences + +- ADR-0029's contract is validated against a host that breaks five of ak's implicit assumptions, + and needed exactly one widening (§4's plain-text capture) plus one guard (§7's absent + `npmPackage`) — both small, both useful independently. +- Local-model operators get a managed host that natively speaks to loopback OpenAI-compatible + endpoints, combined with ADR-0028's provider vocabulary. +- agentic-kit carries no hermes-specific code, no YAML dependency, and no obligation to NousResearch's + release cadence. +- A hermes worker's approval posture is weaker than an OpenCode worker's, permanently and by + hermes's design. §5 makes that legible at enable time and in the terminal result's `host` field. +- `ak sync` writes only on divergence; a *changed* MCP entry is briefly absent mid-sync because of + §3's remove-then-add. Acceptable for a file read at hermes start-up, and recorded so it is not + rediscovered as a bug. + +## Required evidence + +Proposed; no implementation is authorized or claimed. Promotion requires, in the adapter package: + +- Oneshot fixtures end-to-end: success, agent failure (exit 1), no-final-response (exit 1), bad + arguments (exit 2), `--usage-file` written on the failure path, handoff extraction, and + handoff-absent `protocol_error`. +- A recorded non-TTY reproduction of the `add`-overwrite cancellation, proving remove-then-add + converges where a bare re-add does not. +- Restricted-reader fixtures: a normal config, and refusal — not misparse — on anchors, aliases, + multi-document streams, and tags. +- Marker-precise teardown: user-authored `mcp_servers` entries survive `ak host off`; a collision is + preserved and reported. +- Enabled-but-absent, explicit-path, and PATH-resolved detection. + +And in this repository: the §4 plain-text capture, the §7 `npmPackage` guard, and hermes passing +ADR-0029's admission and cap checks unmodified. + +## References + +- Hermes surfaces: `hermes_cli/oneshot.py` (`run_oneshot` — yolo/accept-hooks env, devnull + redirect, single final write, exit codes, `_write_usage_file`), `hermes_cli/mcp_config.py` + (`cmd_mcp_add` / `cmd_mcp_remove` / `cmd_mcp_list`, `_confirm` EOF defaults), + `hermes_cli/config.py` (`config_command`: `show|edit|set|path|env-path|migrate`), + `hermes_cli/runtime_provider.py` (local-server aliases onto `custom`), `mcp_serve.py`, + `pyproject.toml` (`[project.scripts] hermes`). +- ak surfaces this exercises: `src/lib/execution/subprocess.mjs` (plain-text capture), + `src/lib/footprint/install.mjs` (`npmPackage` guard), `src/lib/blocks.mjs` (`customBlocks`), + `src/lib/adapters/*` (admission, caps, ownership). +- ADR-0028 (local provider vocabulary), ADR-0029 (the contract this proves), + ADR-0018 (execution, handoff, trust boundary), ADR-0021 (provenance). diff --git a/docs/adr/README.md b/docs/adr/README.md index 4e39037..59b2609 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -36,6 +36,9 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0025](0025-machine-footprint-metrics.md) | Machine footprint: infrastructure metrics for install, runtime, storage, and catalog | Implemented | | [0026](0026-about-component-directory.md) | About: a component directory that explains everything ak installs | Implemented | | [0027](0027-shared-project-census.md) | One project census, four scopes, every count explains itself | Implemented | +| [0028](0028-local-openai-compatible-providers.md) | One generic local OpenAI-compatible provider, not a vendor enumeration | Proposed | +| [0029](0029-host-adapter-extension-point.md) | Host adapters as a published extension point | Proposed | +| [0030](0030-hermes-reference-adapter.md) | Hermes Agent as the first out-of-tree host adapter | Proposed | Theme: ADRs **0001–0006** define **dual-host LLM routing and leadership** — how `ak` lets ruflo route each development activity (architecture, implementation, testing, review, …) to the right host (Claude @@ -187,3 +190,39 @@ it, while Intelligence folds a repo's sub-directories and throwaway agent worktr identity because that is what a user picks — a distinction that was also a live bug, since keying the picker off identity while listing directories made 7 of 24 rows unreachable. Counts that remain different stay different, and say why. + +**0028–0030** answer the fourth-host question, and answer it once rather than per host. **0028** is +independent of the other two: the registry knows exactly one local provider, `ollama`, while a local +model is normally served as an OpenAI-compatible endpoint on loopback — MLX, LM Studio, `llama.cpp`, +vLLM — and frequently under a name the *user* chose, which no vendor enumeration can cover. It adds +one generic `local-openai` provider that deliberately claims less than `ollama` (no catalogue, no +runtime probe, no digest), puts the runtime's identity in the binding's endpoint rather than in the +provider id, and refuses to assert discovery facts this repository has not measured. + +**0029** observes that 0016 already specified every contract a host adapter needs, 0017 proved them +by using them, and 0018 generalized execution behind them — `sync` and `host pick` already drive +OpenCode through the generic `runLifecycle`. What is left is a last mile: adapter *selection* is a +named import, and `status` hand-rolls a per-host block importing eight functions from +`lib/opencode.mjs`. So it publishes the seam instead of absorbing hosts one at a time. Registration +is explicit in kit.json — never a naming convention, which would let an unrelated `npm install` get +third-party code executed inside ak. In-process adapters cannot be sandboxed, so the trust manifest +**discloses** the package, resolved path, and version before any mutation rather than claiming a +guarantee ak cannot make. External adapters are capped out of `canBePrimary`, `aqeProvider`, and +`commandStatusline` — the three surfaces with first-party obligations — which is exactly the shape +OpenCode already occupies. Admission is fail-closed *per adapter*: a broken third-party adapter is +reported and skipped, never allowed to brick `ak status`, while built-ins keep throwing at +construction because a broken built-in is a build error. Both in-tree refactors delete host-specific +code rather than adding it. + +**0030** is the conformance evidence, because a contract never satisfied by code its authors did not +write is a guess. Hermes Agent breaks five assumptions the built-in hosts share — YAML config, no +npm package, plain-text output, no interceptable permission event, no ruflo backend flag — and +carrying it needed exactly one widening (a plain-text summary capture) and one guard (an absent +`npmPackage`). Two host findings are recorded because they would otherwise resurface as bugs: ak +**never writes hermes's YAML**, driving `hermes config path` / `mcp add` / `mcp remove` instead, and +`mcp add` turns out not to be safely idempotent — its overwrite prompt defaults to No on EOF and +*exits zero*, so a bare re-add is a silent no-op that reads as convergence. And the **approval +posture is disclosed rather than claimed**: `hermes -z` sets `HERMES_YOLO_MODE=1` by its own +headless contract, so unlike an OpenCode worker there is no permission event to intercept and no +`permission_required` result to return — a hermes worker is not presented as carrying a guarantee it +does not have.