docs: propose host adapters as a published extension point (ADR-0028/0029/0030) - #131
docs: propose host adapters as a published extension point (ADR-0028/0029/0030)#131adrianco wants to merge 1 commit into
Conversation
…0029/0030) 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 pacphi#112. All three ADRs are Proposed. No implementation is authorized or claimed.
|
Thanks — this is one of the most carefully argued proposals this repo has received, and it deserves a response in kind. Before evaluating I verified both sides of it: every claim about this codebase against source, and every claim about Hermes against NousResearch/hermes-agent HEAD (v0.20.0, 2026-08-13 — the files your claims rest on last changed 2026-08-08, so HEAD still matches your basis). What checks outEverything load-bearing. On the ak side: adapter selection is indeed a named import at the five call sites, the What I need changed before this moves1. ADR-0029 must supersede ADR-0016's closed-registry clause explicitly. ADR-0016 states as a design requirement that the registry is "a closed, validated registry of built-in code, not an arbitrary third-party plugin runtime" (its non-goals restate it). Publishing the seam reverses that decision — which may well be right, but it has to be recorded as a supersession, not routed around. 2. §8's in-tree gate list is materially incomplete. The biggest omission:
3. Hermes factual corrections.
Disposition
This review also triggered a repo-wide consistency pass on my side: several of the gaps above bite OpenCode today, extension point or not, and I'll sequence that in-tree work separately so your adapter lands against a coherent seam rather than a moving one. |
…28 (F-28/F-29/F-30) (#143) * feat: per-entry provider records and the generic local-openai provider row Phase 1 provider axis (ADR-0028, F-28): providerEntries is six explicit records — capabilities live on each entry instead of being derived from identity comparisons, matching hostEntries; the five pre-existing providers are pinned deep-equal to the old construction. local-openai names any OpenAI-compatible server the user runs: billing local, no credentials, the openai-compatible transport only, projections ruflo/codex/opencode (no aqe — AQE's provider set is upstream's; no claude — that projection expects an anthropic-compatible surface), no discovery claims, no builtin bindings. Remote-endpoint policy pinned: local is a billing claim, not topology — https off-box is legal, plain http stays loopback-only. * feat: ak status names local non-AQE bindings plainly Phase 1 provider axis (F-29): when kit.json declares a binding whose provider is local and not AQE-projected, status prints one info row naming provider, host, and endpoint with the not-an-AQE-provider-type fact. Registry-driven (billing + projections), so ollama never triggers it and a future provider of the same shape gets the same treatment; no bindings means no new output. * docs: accept ADR-0028 with corrections; PROVIDERS.md gains the local-openai recipe Proposed by adrianco in PR #131; accepted with the api_mode citation annotated as invalid per Hermes v0.20.0 source and the ollama/local-openai AQE-projection asymmetry stated as an intentional decision. PROVIDERS.md adds a brief current-state section on declaring a local-openai binding.
…04/F-17/F-14) (#146) * feat: per-entry provider records and the generic local-openai provider row Phase 1 provider axis (ADR-0028, F-28): providerEntries is six explicit records — capabilities live on each entry instead of being derived from identity comparisons, matching hostEntries; the five pre-existing providers are pinned deep-equal to the old construction. local-openai names any OpenAI-compatible server the user runs: billing local, no credentials, the openai-compatible transport only, projections ruflo/codex/opencode (no aqe — AQE's provider set is upstream's; no claude — that projection expects an anthropic-compatible surface), no discovery claims, no builtin bindings. Remote-endpoint policy pinned: local is a billing claim, not topology — https off-box is legal, plain http stays loopback-only. * feat: ak status names local non-AQE bindings plainly Phase 1 provider axis (F-29): when kit.json declares a binding whose provider is local and not AQE-projected, status prints one info row naming provider, host, and endpoint with the not-an-AQE-provider-type fact. Registry-driven (billing + projections), so ollama never triggers it and a future provider of the same shape gets the same treatment; no bindings means no new output. * docs: accept ADR-0028 with corrections; PROVIDERS.md gains the local-openai recipe Proposed by adrianco in PR #131; accepted with the api_mode citation annotated as invalid per Hermes v0.20.0 source and the ollama/local-openai AQE-projection asymmetry stated as an intentional decision. PROVIDERS.md adds a brief current-state section on declaring a local-openai binding. * fix: the execution-adapter invariant no longer bricks ak at import for unwired routable hosts Phase 2 Wave 1 (F-01): the frozen adapter map's bidirectional import-time invariant becomes built-ins-one-directional (a built-in adapter wired to a non-routable or absent host still throws — the in-tree mistake), and executionAdapterFor() is the lookup seam externally-admitted adapters will join. A routable host with no adapter imports cleanly and degrades per worker via the pre-existing cli_unavailable path, which escalation ladders already step past. Built-in behavior byte-identical (same object references). * feat: host lifecycle is reached by registry lookup, uninstall runs the contract, permissions key by host Phase 2 Wave 1 (F-02, F-03, F-04): a lifecycle registry (construction- validated, one-way import) replaces the five OPENCODE_LIFECYCLE_ADAPTER named imports; source-text guards pin that no command names the adapter again. Uninstall tears down every lifecycle host through runLifecycle undo honoring ownership receipts — and persists nulled markers unconditionally like x/host.mjs always has (review-caught: gating the save on file changes stranded a stale mcp:ak receipt forever on the quiet-success path; regression test added). Setup's permission manifest unions auto-approve trust changes across ALL enabled hosts instead of claude's alone — byte-identical at the claude-only default; on opencode-enabled machines its four MCP tool-name rules join the disclosed/authorized set (stated tradeoff, disclosed pre-write by the trust manifest). * refactor: guidance targets derive from the host registry Phase 2 Wave 1 (F-17): guidanceTargets() loops nativeGuidance hosts and their legacy.guidanceFile instead of a closed four-name literal; output pinned byte-identical across every enablement combination. retiredForTarget stops force-stripping targets outside the derived universe. The generic fallback accepts id-shaped names only — a traversal-shaped guidanceFile contributes no target at all (review-caught latent escape; schema-level validation is the wave-4 admission gate's job). * feat: kit.json names its unknown top-level keys instead of ignoring them silently Phase 2 Wave 1 (F-14): loadKitConfig warns once per distinct unknown key-set on stderr — 'preserved, ignored' — so a future hostAdapters key on an older ak is a named no-op, not a silent one. Keys still round-trip untouched; clean configs warn nothing; legacy pre-migration shapes live nested inside recognized envelopes and never trip it.
…mental) (#149) * feat: the host-adapter extension point — data plus consented subprocess hooks (ADR-0029) Phase 2 Wave 4: an external host adapter is a validated JSON manifest plus subprocess hooks; no third-party code ever runs in-process. Behind AK_EXPERIMENTAL_HOST_ADAPTERS=1 with byte-zero default behavior. - manifest.mjs: a STRICT allowlist at every level — top-level, host, install, legacy, capabilities, detection, driving, lifecycle, and trust reject any unknown key, so the structural caps (canBePrimary, aqeProvider, commandStatusline) and a path-traversal guidanceFile are inexpressible, not merely refused; an external adapter may not name an npm package ak would install, and its detection bin must be id-shaped. - admission.mjs: fail-closed, per-adapter isolated admission — validate, cap-check, contract match, builtin-shadow refusal, then a locale-independent content hash checked against hash-pinned consent (edit-invalidated). One bad entry never affects built-ins or siblings. - hook-runner.mjs: the only path external code executes — a supervised, shell-free subprocess with an env allowlist, process-group kill on timeout, and bounded capture. - consent.mjs: the 0600 hash-pinned trust store. - admitted.mjs: the frozen built-ins+admitted overlay; effectiveHostRegistry is HOST_REGISTRY by reference until something is admitted. - Built-in lifecycle loops iterate builtinHostsWithLifecycle() so an admitted host can never enter an opencode-shaped teardown before external lifecycle execution graduates. Security-reviewed (approve-with-nits, no RCE/escape/pollution): six of seven attacks held; the consent-forgery gap and the checklist-not-allowlist finding are closed by the strict allowlist above, verified against the reviewer's exploit probes. * test: adapter-door conformance harness, fixture adapter, and security regression corpus The graduation artifact ADR-0029 names: a real fixture adapter under tests/fixtures/adapters/acme with committed subprocess hooks, plus a black-box conformance harness that admits it through the real consent store, runs its declared hooks as real subprocesses against a marker file, and proves each negative-corpus manifest is refused with its exact named reason. Includes the allowlist, locale-hash, and consent-edit-invalidation regression tests that pin the closed security findings. * docs: ADR-0029 accepts the extension point; supersede ADR-0016's closed-registry clause ADR-0029 (Accepted, experimental contract) records the data-plus-hooks mechanism, credits @adrianco's PR #131 proposal and states what changed and why (grounded in the four-sweep research), carries a graded Working/Demo/TBD table, and formally supersedes ADR-0016's closed-registry clause — narrowly: kit.json may name a manifest, but nothing under it is ever imported in-process, so the clause's intent survives. PROVIDERS.md gains a brief current-state note. kit.json entry shape corrected to {name, source, contract} to match the code. * test: skip the POSIX-mode consent-file assertion on Windows NTFS carries no Unix permission bits, so mode & 0o777 never reflects the 0600 the consent store writes; the write still requests 0600. Guarded with the repo's { skip: process.platform === 'win32' } idiom (windows-only CI failure).
|
Following up now that the design has settled and the work is built. We took ownership of this and implemented it in-tree, grounded in a research pass against live upstream source (this repo, all three hosts, Hermes at HEAD, agentic-qe). The whole program is staged for review in #151 (Phases 0–3, eight merged increments). Disposition of the three proposed ADRs: ADR-0028 (local-openai provider) — accepted, shipped. Landed as the ADR-0029 (host-adapter extension point) — accepted, with a changed mechanism. The seam is real and shipped behind an experimental flag. The one substantive departure from the proposal: instead of dynamically importing an in-process ESM module from a kit.json path, an adapter is a validated JSON manifest plus consented subprocess hooks — no third-party code ever runs in ak's process. That choice is grounded in the research: no surveyed host (Claude Code, Codex, OpenCode) sandboxes or signs extension code, ruflo's typed-but-unenforced capability model is the cautionary tale, and Codex's hash-pinned/edit-invalidated hook trust plus Hermes's consent allowlists are the integrity primitives worth adopting. Capability caps ( ADR-0030 (Hermes reference adapter) — posture accepted; no Hermes code in-tree. A synthetic fixture adapter proves the contract end-to-end (real subprocess hooks, real consent, exact-reason refusal corpus). A real Hermes adapter stays externally maintained by its proposer and waits until the contract graduates from experimental — the four Hermes factual corrections from the original review still apply (no official npm package; A note on the caps, since your proposal is what surfaced the question. ADR-0029 first framed the three caps as permanent. A follow-on decision, ADR-0031, amends that: the block on an adapter self-declaring those capabilities is permanent (it's the safety invariant), but the capability itself is now earnable — through tiered conformance plus an explicit maintainer grant, all the way to promotion into the built-in registry with full parity. So the end-state for a host like Hermes isn't "second-class forever"; it's "prove it through conformance and graduate." ADR-0031 also records the honest upstream-request path for ceilings that aren't ak's to lift (being an AQE provider type is agentic-qe's closed enum; being a native ruflo backend is ruflo's All of this — Phases 0–3, the extension point, ADR-0028/0029/0031, and a consumer-and-implementer explainer — is now merged to |
|
One more note to close the loop: we won't be merging this PR itself. The work it proposed landed through a separate branch that's now merged to A brief heads-up on what's planned next, since it shapes where a Hermes adapter fits. The extension point shipped experimental and admission-only today — it can register an external host but not yet run one. The planned sequence: a command to approve (trust) an adapter; execution so Thanks again for the careful proposal — happy to coordinate whenever you're ready to bring a Hermes adapter through. |
|
@adrianco — the seam you proposed in these ADRs is now built and shipped. As of v4.0.0-alpha.42 (published today), the host-adapter extension point (ADR-0029) and the capability-graduation machinery (ADR-0031) are live, and there's an author-facing walkthrough written with Hermes as the running example: So consider this your green light to start authoring a real Hermes adapter — no The path, end to end (each step maps to a section of the doc):
Two things worth knowing going in (doc §8–9): what works end-to-end today is The bigger picture from §9: a real Hermes adapter is the thing that graduates this contract from experimental to frozen. |
|
Thanks! We will use this and see if we can get Hermes working. |
|
Took you up on the green light. A real Hermes adapter now runs end-to-end under Environment caveat up front: my Hermes is v0.18.2, not the v0.20.0 you verified against. Model is a local MLX server on loopback ( It worksThe worker actually did the work — {
"workerId": "reviewer", "host": "hermes", "durationMs": 14019,
"provider": "hermes", "providerProvenance": "inferred",
"observedModel": "mlx-community--Qwen3-Coder-Next-4bit",
"usage": { "inputTokens": 2687, "outputTokens": 371, "apiCalls": 2 },
"status": "succeeded", "exitCategory": "success"
}Everything I'd want is intact: 1. Conformance
|
| Model | Wall time | API calls | Output tokens |
|---|---|---|---|
Qwen3-Coder-Next-4bit (35B) |
4m22s | — | — |
gpt-oss-20b |
2m01s | 21 | 5,696 |
same adapter, directive prompt ("Reply with exactly: OK") |
3.9s | 1 | 1 |
So the transport is fine; the prompt is what costs four minutes. The same adapter passes a real ak run --timeout 600000 in 50s.
Any of three would fix it, and the first looks strictly better regardless of host: make the probe directive and bounded ("Reply with exactly: OK") so the tier tests the transport rather than the model's ambition; honor the adapter's declared timeoutMs for conformance; or expose --timeout.
2. Conformance points a live agentic worker at the operator's cwd
conformance.mjs calls executeRunPlan(plan, { clock }) with no cwd, so it falls through to runner.mjs:61's cwd = process.cwd() — whatever directory the operator happened to run the command from.
Combine that with (1): an open-ended prompt, a host whose headless mode auto-approves every tool call, and the operator's working directory. My first conformance run was launched from a directory I care about; it was killed at the 120s cap before it wrote anything, which was luck rather than design. The /tmp reproduction shows what it does when it isn't interrupted.
The warning banner lists the hooks that will spawn, but not that a worker will be turned loose in $PWD. A scratch directory for the conformance worker would remove the question entirely.
3. detection.bin can't express a venv install
assertId constrains it to /^[a-z][a-z0-9-]*$/, so it must be a bare PATH lookup. Hermes installs via pip/uv into a venv and is not on PATH — the documented install puts it at ~/.hermes/bin or a project .venv. I had to ln -s ~/hermes-venv/bin/hermes ~/.local/bin/hermes before anything could detect it, and there's no manifest field to say so.
This is ADR-0030 §7's prediction landing in practice, and it'll hit every pip/uv/cargo-installed host. A detection.binPath accepting an absolute path (file-sourced adapters only, since remote sources already can't use relative commands) would cover it without weakening anything.
4. Consent pins the manifest; the hooks are free to change
I edited run-hook.mjs twice after trust recorded consent at a8dde5b5…. Both times the edited hook ran — under ak run and under conformance — with the adapter still reporting trusted and the same hash.
That's consistent with what's documented (the hash is over manifest content) and the manifest does name the hook commands. But the security story is "the manifest is what you consent to, and the hooks are the only part that executes" — and those two facts sit awkwardly together when the executing part isn't covered. conformance also self-consents ("records its own temporary consent and needs no prior trust"), so ak host adapters conformance <name> will run whatever the hook files currently contain.
Not necessarily wrong — pinning hook contents means every edit during development invalidates consent, which is its own kind of painful. But §4's "consent lives outside your code, attached to a specific byte sequence" reads stronger than what's enforced, and a maintainer reviewing hooks at graduation is reviewing content that can change afterward.
An author trap worth a sentence in the doc
I first declared nativeMcpConfig: true / nativeGuidance: true because Hermes genuinely does both — hermes mcp add, and it reads AGENTS.md from cwd. Wrong: my adapter ships no apply/undo hooks, so it doesn't wire either. Capabilities describe what the adapter delivers through ak, not what the host can do in principle. Easy mistake, and the doc's own example manifest (all three false) is right without saying why.
Your four Hermes corrections — all confirmed at source
| Correction | Verified |
|---|---|
api_mode: openai invalid |
_VALID_API_MODES = chat_completions, codex_responses, anthropic_messages, bedrock_converse. _parse_api_mode returns None and drops it silently |
lmstudio first-class, not an alias |
own _normalize_lmstudio_runtime_base_url (auth.py:734), dispatched runtime_provider.py:521,2035 |
--max-turns on chat, not -z |
sets HERMES_TUI_MAX_TURNS (main.py:2093); no oneshot equivalent |
cmd_config in subcommands/config.py |
correct — my ADR-0030 citation was wrong |
My own config had the invalid api_mode: openai, exactly as you predicted. It's benign — URL detection supplies the transport and runs succeed — but the line isn't doing what it appears to.
One thing I chased and got wrong
An early run wrote HELLO.txt into $HOME rather than the worker cwd, and I suspected AK_WORKER_CWD wasn't being set. It is — instrumenting the hook showed AK_WORKER_CWD=/private/tmp/ak-hermes-run2 and hookCwd correctly anchored to the adapter directory. I couldn't reproduce the $HOME write in three subsequent attempts; the likeliest explanation is the model choosing an absolute path that once.
The general point survives the retraction though: AK_WORKER_CWD is advisory. Nothing confines an auto-approving worker to it, and for Hermes specifically there's no permission event to intercept. Worth a line in §3 next to the AK_WORKER_CWD description — an adapter author reading it today could reasonably assume it's a boundary.
Where I'd take it next
Happy to publish the adapter (manifest + hooks, ~150 lines total) wherever is useful. It can't clear activity-routing until (1) is resolved, so it isn't graduation-ready — but the tier is failing on harness ergonomics rather than on the adapter, and a real ak run already proves the path works. If a bounded probe prompt lands, I'll re-run and report.
The contract shape itself held up well. Everything above is ergonomics and disclosure, not a shape problem — which I think is the answer §9 was hoping the first real adapter would produce.
Documentation only. Three ADRs, all Proposed — no implementation is authorized or claimed. Follows the shape of #112.
The short version
I wanted agentic-kit to manage a fourth agent CLI (Hermes Agent, which is what I use to drive local models). The obvious route was ADR-0017's: an owner module and edits across nine files. Its own references section lists fourteen source files and eight test suites for one host.
That route makes every host your permanent obligation, including hosts you don't run and can't verify. So this proposes the smaller thing instead: publish the seam that already exists.
The seam is already built, and already partly adopted
Reading through 0016/0017/0018, every contract a host adapter needs is already specified and enforced:
validateHostAdaptervalidateRegistries(at construction)validateLifecycleAdapter— detect/plan/apply/verify/undoownership,mayUndo,undoOwnedValuesvalidateExecutionAdapter,validateWorkerResultnormalizedFacts(schemaVersion: 1, provenance-bearing)registry(customBlocks)— already user-extensibleAnd
OPENCODE_LIFECYCLE_ADAPTERimplements the full five-verb contract, driven fromsync.mjsandx/host.mjsthrough the genericrunLifecycle.What's left is a last mile, and it's small:
runLifecycle({ adapter: OPENCODE_LIFECYCLE_ADAPTER, … })status.mjshand-rolls a per-host block importing eight functions fromlib/opencode.mjs, even thoughdetectalready returnsnormalizedFactsBoth changes delete host-specific code rather than adding it, which is why they seem worth making even if no external adapter is ever registered.
ADR-0029 — the actual ask
An adapter is one module exporting one manifest, validated by the validators above. Four constraints make it safe to publish rather than merely convenient:
kit.jsonregistration, never naming-convention discovery. Scanning forak-host-*would make an unrelatednpm installsufficient to get third-party code executed inside ak on the nextak status— the fail-open pattern ADR-0023 exists to prevent.canBePrimary,aqeProvider, orcommandStatusline. Those three carry first-party obligations you can't discharge for code you don't ship. It's exactly the shape OpenCode already occupies, so it's a tested configuration, not new policy.ak status. Built-ins keep throwing at construction, because a broken built-in is a build error.Plus
contract: 1and an explicit statement that the surface is unstable while the package is alpha — publishing an extension point acquires an obligation, and that statement is what bounds it.The package stays zero-runtime-dependency: an adapter is something the user installs and registers, never a dependency of
@pacphi/agentic-kit.ADR-0030 — conformance evidence
A contract never satisfied by code its authors didn't write is a guess. Hermes is a useful first consumer because it's awkward — it breaks five assumptions the built-in hosts share: YAML config, no npm package, plain-text output, no interceptable permission event, no ruflo
ENABLE_*flag.Carrying it needed one widening (a plain-text summary capture alongside
createJsonlSummaryCapture) and one guard (npmRoot(host.install.npmPackage)infootprint/install.mjs, since hermes is the first host with no npm package). Everything else fit unmodified.It would ship as an externally maintained adapter — not vendored here, and not asking you to take on hermes's correctness or NousResearch's release cadence. I'd maintain it.
Two findings are in the ADR because they'd otherwise resurface as bug reports:
hermes mcp addisn't safely idempotent — its overwrite prompt defaults to No on EOF (which is what a non-TTYak syncsupplies) and exits zero, so a bare re-add is a silent no-op that reads as convergence.hermes -zsetsHERMES_YOLO_MODE=1by its own headless contract, so unlike OpenCode there's no permission event to intercept and nopermission_requiredresult to return. ADR-0030 discloses that at enable time rather than letting a hermes worker appear to carry a guarantee it doesn't have.ADR-0028 — independent of both
The registry knows exactly one local provider,
ollama, while a local model is normally an OpenAI-compatible loopback endpoint (MLX, LM Studio, llama.cpp, vLLM) — frequently under a name the user chose, which no vendor enumeration can cover. One genericlocal-openairow that deliberately claims less thanollama: no catalogue, no runtime probe, no digest, and no discovery facts this repo hasn't measured.Useful to the hosts you already ship, and lands on its own merits regardless of what happens to 0029/0030.
Sequencing
docs/HOST-ADAPTER-EXTENSION-PROPOSAL.md§7 lays this out; each step is independently reversible, and declining at ADR-0029 costs nothing already spent. §8 is an honest case against the proposal, including the real cost: a published contract acquires consumers, and consumers constrain refactors.Happy to take this in a different direction — including "no, absorb hermes in-tree the ADR-0017 way" or "not now" — the ADRs are written to be argued with rather than merged as-is. Unrelated: #130 is a small bug fix that currently blocks new test evidence on Homebrew-node macOS.
markdownlintand internal link checks pass; no source changes in this PR.