Skip to content

docs: propose host adapters as a published extension point (ADR-0028/0029/0030) - #131

Open
adrianco wants to merge 1 commit into
pacphi:mainfrom
adrianco:docs/hermes-adrs
Open

docs: propose host adapters as a published extension point (ADR-0028/0029/0030)#131
adrianco wants to merge 1 commit into
pacphi:mainfrom
adrianco:docs/hermes-adrs

Conversation

@adrianco

Copy link
Copy Markdown
Contributor

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:

Contract Validator
Host descriptor + capabilities validateHostAdapter
Cross-axis invariants validateRegistries (at construction)
Configuration lifecycle validateLifecycleAdapter — detect/plan/apply/verify/undo
Ownership + teardown ownership, mayUndo, undoOwnedValues
Worker execution validateExecutionAdapter, validateWorkerResult
Normalized facts normalizedFacts (schemaVersion: 1, provenance-bearing)
Guidance rows registry(customBlocks) — already user-extensible

And OPENCODE_LIFECYCLE_ADAPTER implements the full five-verb contract, driven from sync.mjs and x/host.mjs through the generic runLifecycle.

What's left is a last mile, and it's small:

  • adapter selection is a named import — runLifecycle({ adapter: OPENCODE_LIFECYCLE_ADAPTER, … })
  • status.mjs hand-rolls a per-host block importing eight functions from lib/opencode.mjs, even though detect already returns normalizedFacts

Both 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:

  1. Explicit kit.json registration, never naming-convention discovery. Scanning for ak-host-* would make an unrelated npm install sufficient to get third-party code executed inside ak on the next ak status — the fail-open pattern ADR-0023 exists to prevent.
  2. Disclosure, not a sandbox claim. In-process modules can't be sandboxed and the ADR doesn't pretend otherwise; the trust manifest names the package, resolved path, and version before any mutation.
  3. Capability caps — no canBePrimary, aqeProvider, or commandStatusline. 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.
  4. Fail-closed per adapter. A broken third-party adapter is reported and skipped, never able to brick ak status. Built-ins keep throwing at construction, because a broken built-in is a build error.

Plus contract: 1 and 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) in footprint/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 add isn't safely idempotent — its overwrite prompt defaults to No on EOF (which is what a non-TTY ak sync supplies) and exits zero, so a bare re-add is a silent no-op that reads as convergence.
  • hermes -z sets HERMES_YOLO_MODE=1 by its own headless contract, so unlike OpenCode there's no permission event to intercept and no permission_required result 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 generic local-openai row that deliberately claims less than ollama: 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.

markdownlint and internal link checks pass; no source changes in this PR.

…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.
@pacphi

pacphi commented Aug 13, 2026

Copy link
Copy Markdown
Owner

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 out

Everything load-bearing. On the ak side: adapter selection is indeed a named import at the five call sites, the status.mjs opencode block and unguarded npmRoot are as described, and the validators are genuinely host-agnostic — including a synthetic-host seam you didn't cite (trust-manifest.test.mjs:47-69 already injects a grok host end-to-end through setup disclosure). On the Hermes side, both safety findings are confirmed at source level: the mcp add EOF-cancel does exit zero — in fact it's stronger than you claim, since cmd_mcp swallows every handler return, so no hermes mcp subcommand except install can signal failure via exit code — and -z sets the YOLO/accept-hooks env with the devnull-redirected call tree, final-write stdout channel, and 0/1/2 exit codes exactly as documented. mcp serve is confirmed as a pure messaging bridge (ten tools, none delegation-shaped); I agree the reverse bridge must stay excluded.

What I need changed before this moves

1. 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: src/lib/execution/adapters.mjs:20-30 enforces a bidirectional invariant between the routable-host set and a frozen 3-entry adapter map at module import. A kit.json-registered host claiming canRouteActivities: true — which ADR-0030 §2 declares — bricks ak run for everyone at import, not at dispatch. That invariant needs to become "every built-in routable host…" plus a merge seam (ADR-0019's cli_unavailable degradation is the in-corpus precedent for the softer rule). Also missing from §8:

  • ak uninstall bypasses runLifecycle entirely (uninstall.mjs:112-142) — a third-party footprint would persist forever.
  • Setup's permission pass authorizes only claude-host rules; removeUndisclosedPermissions (setup.mjs:120-128) would strip a third-party host's permissions and fail setup.
  • An explicit policy for the attribution surfaces: the usage scorecard collapses unknown hosts into claude (usage-index.mjs:1407-1408), live sessions rewrite them to 'internal', and qe-court's vendorOf maps them to 'unknown' — which can distort vendor-diversity findings in either direction. "Excluded and labeled" is an acceptable policy for all three; discovering them as bugs is not.
  • ~12 test assertions pin the host set (hosts.test.mjs:10-11, providers.test.mjs:222-225, the about-directory parity trio, plus five deepEquals on the DEFAULTS literal), and the test-enforced registry↔directory parity means a registered host with no authored card fails CI.
  • kit.json silently round-trips unknown top-level keys, so hostAdapters on an older ak is a silent no-op — it needs at least a warning story.

3. Hermes factual corrections.

  • "No npm package" → "no official npm package": an unofficial bridge (hermes-agent by wyrtensi, currently v0.20.0) publishes hermes/hermes-agent bins via npm, so detection must not infer identity or version from npm presence.
  • lmstudio is a first-class Hermes provider with its own base-URL normalizer, not an alias onto custom; there is no mlx alias (MLX goes through a custom endpoint, per Hermes's own local-LLM guide) — which, if anything, strengthens ADR-0028's user-named-provider argument.
  • ADR-0028's quoted reference config uses api_mode: openai, which Hermes's _parse_api_mode silently drops — the valid value is chat_completions (newer key spelling: transport). Worth fixing both the citation and, on your machine, the config.
  • --max-turns does exist on hermes chat (combinable with non-interactive -q) — your claim is correct for -z, but say so precisely. Minor: the config dispatcher is cmd_config in hermes_cli/subcommands/config.py, not config_command in hermes_cli/config.py.

Disposition

  • ADR-0028: accept after the api_mode fix, plus a note that ollama projects to AQE while local-openai deliberately doesn't — right call, but it should be stated so it doesn't read as a bug later. The providerEntries per-entry refactor in §4 is correct and needed regardless of the rest of this PR.
  • ADR-0029: directionally yes — amend per (1) and (2), then I'll accept. I'd rather the ADR carry the honest gate list than the honest case-against alone.
  • ADR-0030: sound posture — the disclosure-over-guarantee treatment of -z and the mcp serve exclusion are exactly right; apply (3).

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.

pacphi added a commit that referenced this pull request Aug 15, 2026
…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.
pacphi added a commit that referenced this pull request Aug 15, 2026
…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.
pacphi added a commit that referenced this pull request Aug 15, 2026
…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).
@pacphi

pacphi commented Aug 16, 2026

Copy link
Copy Markdown
Owner

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 local-openai provider row with per-entry capability records, crediting your proposal in the ADR. Corrections applied: the api_mode: openai in the reference config is annotated as invalid (Hermes's _parse_api_mode silently drops it; the valid value is chat_completions), and the ollama/local-openai AQE-projection asymmetry is stated as an intentional decision.

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 (canBePrimary/aqeProvider/commandStatusline) are enforced structurally — inexpressible in the manifest schema, not runtime-checked. ADR-0029 also formally supersedes ADR-0016's closed-registry clause (narrowly — nothing is imported in-process). The full §8 gate list you'd want is addressed: the import-time invariant, uninstall-through-undo, host-keyed permissions, the attribution surfaces, the test pins, and the unknown-key warning all landed across the earlier phases.

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; lmstudio is first-class not an alias; -z auto-approves; mcp add EOF is a silent no-op).

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 ENABLE_* model).

All of this — Phases 0–3, the extension point, ADR-0028/0029/0031, and a consumer-and-implementer explainer — is now merged to main. Thank you for the proposal; it was carefully argued and it drove a substantial, well-tested improvement to the host/provider model, with credit recorded in the ADRs.

@pacphi

pacphi commented Aug 16, 2026

Copy link
Copy Markdown
Owner

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 main, with your proposal credited in the ADRs. So think of #131 as the origin of the design rather than the delivery vehicle — happy to leave it open as that record.

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 ak run can actually drive an admitted host; a tiered conformance kit; and earned capability grants that let an adapter graduate toward full parity, up to and including shipping as a built-in. Where a capability genuinely isn't ours to grant — being an AQE provider type, or a native ruflo backend — we'll carry that upstream as a concrete request rather than fake local support. A real Hermes adapter graduates once it clears the conformance kit.

Thanks again for the careful proposal — happy to coordinate whenever you're ready to bring a Hermes adapter through.

@pacphi

pacphi commented Aug 16, 2026

Copy link
Copy Markdown
Owner

@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: docs/AUTHORING-HOST-ADAPTERS.md.

So consider this your green light to start authoring a real Hermes adapter — no ak code, no module in this repo, no fork. It's a JSON manifest plus a couple of subprocess hooks that ak spawns and supervises; nothing you write ever runs inside the ak process.

The path, end to end (each step maps to a section of the doc):

  • 1. Write the manifest (ak-adapter.json) — host id: hermes, all eight capabilities keys (declare only what Hermes legitimately does), a detection block (bin / --version / version regex), driving.surfaces: ["cli-subprocess"] (the only surface with a working implementation today), and a trust.changes disclosure of what your adapter touches. Heads-up: canBePrimary, commandStatusline, and aqeProvider are not self-assertable — the schema rejects them at anything but false/absent. You earn those through conformance tiers; you don't write them down.
  • 2. Write the hooks — ordinary executables, no SDK, no imports from ak. Start with detect (prints the host it found) and execution.run (drives Hermes as a worker under ak run: prompt on stdin, AK_WORKER_* in the env including AK_WORKER_CWD for the repo to work on, JSON or plain text on stdout, and the exit code is the sole authority for success). Use exit 78 for "needs login" and 77 for "refused" so those read honestly instead of as a generic failure — and write your JSON payload to stdout only.
  • 3. Register + opt in — add a hostAdapters entry to your kit.json, export AK_EXPERIMENTAL_HOST_ADAPTERS=1, then ak host adapters trust hermes. Trust discloses the full validated manifest (every hook command that will spawn is right there) and pins a hash of it — edit the manifest afterward and consent invalidates until you re-confirm.
  • 4. Self-testak host adapters conformance hermes runs the tiered black-box harness against your real hooks. Expect activity-routing to pass end-to-end (that's the path that fully works today); session-driving and statusline come back gated/skipped, which is expected, not your adapter failing — those ceilings are upstream, not yours. Only failed means something's actually wrong.
  • 5. Propose for graduation — hand a maintainer the conformance report; they re-run it, read your hooks, and either bless the earned tiers (blessed external adapter, stays out-of-tree) or promote it to a built-in. For the genuinely upstream-owned tiers, ak host adapters gate hermes session-driving <ruflo-ref> records the blocker so status shows exactly what's pending.

Two things worth knowing going in (doc §8–9): what works end-to-end today is activity-routing — a supervised ak run worker on Hermes with a structured result. A granted canBePrimary / commandStatusline is currently inert (ak host pick is still built-in-scoped, and there's no admitted-host footer-render path yet) — that's ak-local work to light up, not anything blocked on you. And the two genuine ceilings — native ruflo backend (session-driving) and the AQE provider type (aqeProvider) — already have their upstream capability requests filed (#162).

The bigger picture from §9: a real Hermes adapter is the thing that graduates this contract from experimental to frozen. contract: 1 stays deliberately fluid between alpha releases precisely so your first real conformance run can surface any shape problems cheaply. So you're not just a beneficiary of this machinery — you're the test that proves it. Happy to pair on the manifest or re-run conformance whenever you've got something to look at.

@adrianco

Copy link
Copy Markdown
Contributor Author

Thanks! We will use this and see if we can get Hermes working.

@adrianco

Copy link
Copy Markdown
Contributor Author

Took you up on the green light. A real Hermes adapter now runs end-to-end under ak run on alpha.42 — manifest + two subprocess hooks, no ak code, no fork. Below is what worked, what broke, and four things I'd change.

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 (Qwen3-Coder-Next-4bit, and gpt-oss-20b for one comparison) — so this is the local-model case the whole thread has been about, and some timings are specific to it.

It works

run: packaging
  packager     hermes    succeeded (success)
  reviewer     hermes    succeeded (success)
✓ run complete                                    (50s wall, --timeout 600000)

The worker actually did the work — HELLO.txt containing hermes-via-ak, written into the worker cwd, verified on disk. The WorkerResult:

{
  "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: providerProvenance: "inferred" (not laundered), Hermes's own --usage-file accounting carried through, AK_WORKER_CWD correctly set to the target repo, and hookCwd correctly pinned to the adapter's own directory. The disclosure at trust was genuinely useful — it surfaced my YOLO-mode warning verbatim, which is exactly the thing a user should see before consenting.

1. Conformance activity-routing cannot pass on a local model

✓ admission          passed   host id 'hermes', contract 1
✗ activity-routing   failed   hermes:run adapter hook timed out after 119746ms and was killed

Reproducible. Two compounding causes:

The 120s budget is not overridable here. runner.mjs:61 defaults timeoutMs = 120_000; ak run exposes --timeout, but ak host adapters conformance doesn't. My manifest declares timeoutMs: 900000 and loses — correctly per the documented "tighter one wins", but that leaves no way to conform.

The probe prompt is open-ended, and an agentic host treats it as a task. conformance.mjs:234 sends 'conformance harness probe'. Hermes read that as an instruction to build a conformance harness, and did — a full Python tree (run.py, config.yaml, probes/, README.md, fixtures/) in the worker cwd:

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants